diff --git a/backend/app/agendas/outcome_tags.py b/backend/app/agendas/outcome_tags.py index 668c287..bb37855 100644 --- a/backend/app/agendas/outcome_tags.py +++ b/backend/app/agendas/outcome_tags.py @@ -27,11 +27,20 @@ class TaggedOutcome: _TAGS: tuple[tuple[OutcomeKind, str], ...] = ( ("decision", "@决策:"), + ("decision", "@Decision:"), + ("decision", "@decision:"), ("action", "@行动:"), + ("action", "@Action:"), + ("action", "@action:"), ("question", "@开放问题:"), ("question", "@问题:"), + ("question", "@Question:"), + ("question", "@question:"), + ("question", "@OpenQuestion:"), ) +_ACTION_PREFIXES: tuple[str, ...] = ("@行动:", "@Action:", "@action:") + def parse_action_metadata( raw_payload: str, @@ -79,6 +88,7 @@ def format_action_tag( status: ActionStatus | str = ActionStatus.open, owner: str | None = None, due_date: date | str | None = None, + tag_prefix: str = "@行动:", ) -> str: """Format an ActionItem into the tag syntax @行动:[status][@owner][due_date] content.""" status_str = status.value if hasattr(status, "value") else str(status) @@ -100,7 +110,7 @@ def format_action_tag( due_str = due_date.isoformat() if hasattr(due_date, "isoformat") else str(due_date).strip() if due_str: parts.append(f"[{due_str}]") - return f"@行动:{''.join(parts)} {content.strip()}" + return f"{tag_prefix}{''.join(parts)} {content.strip()}" def rewrite_action_in_notes( @@ -124,12 +134,19 @@ def rewrite_action_in_notes( continue if in_fence: continue - if line.startswith("@行动:"): + matched_prefix = next( + (prefix for prefix in _ACTION_PREFIXES if line.startswith(prefix)), + None, + ) + if matched_prefix is not None: if action_count == target_index: leading_ws_len = len(raw_line) - len(raw_line.lstrip()) leading = raw_line[:leading_ws_len] ending = "\n" if raw_line.endswith("\n") else "" - lines[idx] = f"{leading}{new_tag_line}{ending}" + tag_line = new_tag_line + if matched_prefix != "@行动:" and tag_line.startswith("@行动:"): + tag_line = f"{matched_prefix}{tag_line[len('@行动:'):]}" + lines[idx] = f"{leading}{tag_line}{ending}" return "".join(lines) action_count += 1 return notes_markdown diff --git a/backend/tests/domain/test_bidirectional_actions.py b/backend/tests/domain/test_bidirectional_actions.py index f8b878b..5cc054b 100644 --- a/backend/tests/domain/test_bidirectional_actions.py +++ b/backend/tests/domain/test_bidirectional_actions.py @@ -320,3 +320,38 @@ def test_reconcile_outcomes_preserves_final_decision_clears_metadata_and_handles ) assert session.get(Decision, final_dec.id) is not None assert session.get(Decision, final_dec.id).status.value == "final" + + +def test_parse_and_rewrite_english_outcome_tags(): + md = """# Meeting Notes +@Decision: Adopt canonical REST API contract +@Action:[todo][@alice][2026-10-18] Ship bilingual UI toggle +@Question: Should we preserve legacy webhook aliases? +""" + tags = parse_outcome_tags(md) + assert len(tags) == 3 + assert tags[0].kind == "decision" + assert tags[0].source_tag_key == "decision:0" + assert tags[0].content == "Adopt canonical REST API contract" + + assert tags[1].kind == "action" + assert tags[1].source_tag_key == "action:0" + assert tags[1].status == ActionStatus.open + assert tags[1].owner_name == "alice" + assert tags[1].due_date == date(2026, 10, 18) + assert tags[1].content == "Ship bilingual UI toggle" + + assert tags[2].kind == "question" + assert tags[2].source_tag_key == "question:0" + assert tags[2].content == "Should we preserve legacy webhook aliases?" + + # Rewriting an English @Action: tag preserves the @Action: prefix + new_tag = format_action_tag( + content="Ship bilingual UI toggle", + status=ActionStatus.done, + owner="alice", + due_date=date(2026, 10, 18), + ) + rewritten = rewrite_action_in_notes(md, "action:0", new_tag) + assert "@Action:[done][@alice][2026-10-18] Ship bilingual UI toggle" in rewritten + diff --git a/docs/assets/demo_meetflow.gif b/docs/assets/demo_meetflow.gif index 413f645..163dd25 100644 Binary files a/docs/assets/demo_meetflow.gif and b/docs/assets/demo_meetflow.gif differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8cb1aef..1aac7e4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,8 +4,8 @@ import { NBadge, NConfigProvider, NDialogProvider, NDropdown, NIcon, NLayout, NLayoutContent, NMessageProvider, dateZhCN, zhCN, } from 'naive-ui' -import { onBeforeUnmount, onMounted, watch } from 'vue' -import { RouterLink, RouterView, useRouter } from 'vue-router' +import { computed, onBeforeUnmount, onMounted, watch } from 'vue' +import { RouterView, useRouter } from 'vue-router' import { api } from './api/client' import { clearSession, session, type SessionUser } from './auth/session' @@ -13,15 +13,17 @@ import AppAvatar from './components/AppAvatar.vue' import AppSidebar from './components/AppSidebar.vue' import { resetUnread, startUnreadPolling, unreadCount } from './composables/useInboxUnread' import { resetUserNames } from './composables/useUserNameMap' +import { useI18n } from './i18n' import { loadPluginFrontendModules } from './plugins/runtime' import { naiveThemeOverrides } from './theme/naive' const router = useRouter() +const { locale, toggleLocale, tr } = useI18n() -const accountOptions = [ - { key: 'account', label: '账号设置' }, - { key: 'logout', label: '退出登录' }, -] +const accountOptions = computed(() => [ + { key: 'account', label: tr('账号设置', 'Account Settings') }, + { key: 'logout', label: tr('退出登录', 'Sign Out') }, +]) function onLoggedIn(user: SessionUser) { session.user = user @@ -78,7 +80,11 @@ onBeforeUnmount(() => { + diff --git a/frontend/src/api/meetings.ts b/frontend/src/api/meetings.ts index e8d20bb..21c4aa8 100644 --- a/frontend/src/api/meetings.ts +++ b/frontend/src/api/meetings.ts @@ -1,5 +1,6 @@ import { api, apiDownload } from './client' import type { Meeting, MeetingParticipantWrite } from '../domain/meetings' +import { locale, type Locale } from '../i18n' export type LifecycleAction = 'start' | 'finish' | 'cancel' | 'reopen' @@ -75,11 +76,23 @@ export function rotateApiKey() { }) } -export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: string): string { - const title = agendaTitle.trim() || '当前议题' +const OUTCOME_TAG_LINE_RE = /^@(?:决策|行动|开放问题|问题|Decision|decision|Action|action|Question|question|OpenQuestion):/ +const BULLET_OUTCOME_TAG_RE = /^\s*[-*•]\s*(@(?:决策|行动|开放问题|问题|Decision|decision|Action|action|Question|question|OpenQuestion):.+)$/ + +export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: string, lang: Locale = locale.value): string { + const useEn = lang === 'en' + const title = agendaTitle.trim() || (useEn ? 'Current Agenda' : '当前议题') const trimmed = rawMarkdown.trim() if (!trimmed) { + if (useEn) { + return [ + `### ${title} · Key Discussion Points`, + `@Decision: Confirm core implementation plan and rollout path for "${title}"`, + `@Action:[open] Complete API integration and automated regression tests for "${title}"`, + `@Question: Does the legacy data migration window for "${title}" require canary verification?`, + ].join('\n') + } return [ `### ${title} · 讨论要点`, `@决策: 确认「${title}」核心实施方案与演进路径`, @@ -93,9 +106,13 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri const existingTags = new Set() const extractedTags: string[] = [] + const decPrefix = useEn ? '@Decision:' : '@决策:' + const actPrefix = useEn ? '@Action:' : '@行动:' + const qPrefix = useEn ? '@Question:' : '@开放问题:' + for (const originalLine of lines) { const rawLine = originalLine.replace(/\\([[\]])/g, '$1') - const bulletTagMatch = rawLine.match(/^\s*[-*•]\s*(@(?:决策|行动|开放问题|问题):.+)$/) + const bulletTagMatch = rawLine.match(BULLET_OUTCOME_TAG_RE) if (bulletTagMatch) { const tagLine = bulletTagMatch[1].trim() normalizedLines.push(tagLine) @@ -104,7 +121,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri } const stripped = rawLine.trim() - if (/^@(?:决策|行动|开放问题|问题):/.test(stripped)) { + if (OUTCOME_TAG_LINE_RE.test(stripped)) { normalizedLines.push(stripped) existingTags.add(stripped) continue @@ -113,28 +130,62 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri normalizedLines.push(rawLine) const cleanLine = stripped.replace(/^[-*•\d.)\s]+/, '').trim() - if (!cleanLine || cleanLine.startsWith('#') || cleanLine.startsWith('```')) { + if ( + !cleanLine + || cleanLine.startsWith('#') + || cleanLine.startsWith('```') + || /[::]$/.test(cleanLine) + ) { continue } - if (/(?:决定|确定|决议|统一采用|采用|拍板|一致同意|定稿|结论[::])/.test(cleanLine)) { - const content = cleanLine.replace(/^(?:团队一致决定|一致决定|决定|确定|决议|结论[::])\s*/, '').trim() + if ( + /(?:待确认|疑问|是否|如何|待评估|[??]$)/.test(cleanLine) + || /\b(?:question|tbd|whether|how to|need to confirm|open question)\b/i.test(cleanLine) + ) { + const content = cleanLine + .replace(/^(?:待确认[::]?|疑问[::]?|问题[::]?|open question[::]?|question[::]?|tbd[::]?)\s*/i, '') + .trim() + if (content) { + const tag = `${qPrefix} ${content}` + if (!existingTags.has(tag)) extractedTags.push(tag) + } + } else if ( + /^(?:请\s*@|待办|行动|action|todo)/i.test(cleanLine) + || (/@[a-zA-Z0-9_-]+/.test(cleanLine) && /(?:请|负责|跟进|待办|完成|完善|推进|输出|补充|联调|验证|编写|提交)/.test(cleanLine)) + ) { + const content = cleanLine + .replace(/^(?:待办[::]?|行动[::]?|action[::]?|todo[::]?)\s*/i, '') + .trim() if (content) { - const tag = `@决策: ${content}` + const ownerMatch = content.match(/@([a-zA-Z0-9_-]+)/) + const ownerTag = ownerMatch ? `[@${ownerMatch[1]}]` : '' + const tag = `${actPrefix}[open]${ownerTag} ${content}` if (!existingTags.has(tag)) extractedTags.push(tag) } - } else if (/(?:待确认|疑问|是否|如何|待评估|[??]$)/.test(cleanLine)) { - const content = cleanLine.replace(/^(?:待确认[::]?|疑问[::]?|问题[::]?)\s*/, '').trim() + } else if ( + /(?:决定|确定|决议|统一采用|采用|拍板|一致同意|定稿|结论[::])/.test(cleanLine) + || /\b(?:decided|decide|agreed|agree|approved|approve|adopted|adopt|concluded|decision)\b/i.test(cleanLine) + || /^(?:decision|conclusion)[::]/i.test(cleanLine) + ) { + const content = cleanLine + .replace(/^(?:团队一致决定|一致决定|决定|确定|决议|结论[::]|decision[::]|conclusion[::])\s*/i, '') + .trim() if (content) { - const tag = `@开放问题: ${content}` + const tag = `${decPrefix} ${content}` if (!existingTags.has(tag)) extractedTags.push(tag) } - } else if (/(?:负责|跟进|待办|完成|推进|输出|补充|联调|验证|编写|提交)/.test(cleanLine)) { - const content = cleanLine.replace(/^(?:待办[::]?|行动[::]?)\s*/, '').trim() + } else if ( + /(?:负责|跟进|待办|完成|完善|推进|输出|补充|联调|验证|编写|提交)/.test(cleanLine) + || /\b(?:will|todo|action|follow up|implement|complete|verify|test|prepare|submit|assign)\b/i.test(cleanLine) + ) { + const content = cleanLine + .replace(/^(?:待办[::]?|行动[::]?|action[::]?|todo[::]?)\s*/i, '') + .trim() if (content) { const ownerMatch = content.match(/@([a-zA-Z0-9_-]+)/) const ownerTag = ownerMatch ? `[@${ownerMatch[1]}]` : '' - const tag = `@行动:[open]${ownerTag} ${content}` + const tag = `${actPrefix}[open]${ownerTag} ${content}` if (!existingTags.has(tag)) extractedTags.push(tag) } } @@ -146,6 +197,17 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri } if (existingTags.size === 0) { + if (useEn) { + return [ + normalizedLines.join('\n'), + '', + `@Decision: Confirm rollout plan for "${title}"`, + '', + `@Action:[open] Follow up on implementation and integration testing for "${title}"`, + '', + `@Question: Cross-module boundary details for "${title}" pending final confirmation`, + ].join('\n') + } return [ normalizedLines.join('\n'), '', @@ -160,3 +222,4 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri return normalizedLines.join('\n') } + diff --git a/frontend/src/components/AgendaDetail.vue b/frontend/src/components/AgendaDetail.vue index 45f6f26..7cc6187 100644 --- a/frontend/src/components/AgendaDetail.vue +++ b/frontend/src/components/AgendaDetail.vue @@ -17,8 +17,10 @@ import type { MarkdownEditorHandle } from './MarkdownEditor.vue' import OutcomeComposer from './OutcomeComposer.vue' import PluginEditorSlot from './PluginEditorSlot.vue' import VersionConflictDialog from './VersionConflictDialog.vue' +import { useI18n } from '../i18n' import { formatDateTime, formatDuration, parseUtcTimestamp } from '../utils/time' +const { locale, tr } = useI18n() const props = defineProps<{ meeting: Meeting; item: AgendaItem; canContribute: boolean }>() const emit = defineEmits<{ changed: []; advance: [nextId: string | null] }>() type AgendaAdvanceResult = { next_agenda_item_id: string | null } @@ -103,13 +105,13 @@ const migrateOptions = computed(() => props.meeting.agenda_items const actualDurationText = computed(() => { const seconds = props.item.actual_duration_seconds if (props.item.status !== 'completed' || seconds === null || seconds === undefined) return '' - return `实际用时 ${formatDuration(seconds)}` + return `${tr('实际用时', 'Actual')} ${formatDuration(seconds)}` }) const completedWindow = computed(() => { if (props.item.status !== 'completed') return '' const parts: string[] = [] - if (props.item.started_at) parts.push(`开始 ${formatDateTime(props.item.started_at)}`) - if (props.item.completed_at) parts.push(`完成 ${formatDateTime(props.item.completed_at)}`) + if (props.item.started_at) parts.push(`${tr('开始', 'Start')} ${formatDateTime(props.item.started_at)}`) + if (props.item.completed_at) parts.push(`${tr('完成', 'End')} ${formatDateTime(props.item.completed_at)}`) return parts.join(' · ') }) const liveElapsed = computed(() => { @@ -253,7 +255,7 @@ async function smartExtractNotes() { try { const flushed = typeof notesEditor.value?.flush === 'function' ? notesEditor.value.flush() : undefined if (flushed !== undefined) draft.notes_markdown = flushed - const structured = smartStructureAgendaNotes(draft.notes_markdown, draft.title || props.item.title) + const structured = smartStructureAgendaNotes(draft.notes_markdown, draft.title || props.item.title, locale.value) draft.notes_markdown = structured const res = await ingestMeetingNotes(props.meeting.id, { @@ -276,7 +278,9 @@ async function smartExtractNotes() { const decisions = res.decisions ?? res.extracted?.decisions ?? 0 const actions = res.actions ?? res.extracted?.actions ?? 0 const questions = res.questions ?? res.extracted?.questions ?? 0 - extractStatus.value = `✨ 已智能提炼 ${decisions} 项决策 · ${actions} 项行动 · ${questions} 项开放问题` + extractStatus.value = locale.value === 'en' + ? `✨ Extracted ${decisions} decisions · ${actions} actions · ${questions} open questions` + : `✨ 已智能提炼 ${decisions} 项决策 · ${actions} 项行动 · ${questions} 项开放问题` emit('changed') } catch (caught) { error.value = errorMessage(caught, 'AI 提炼失败,请检查议题记录格式') @@ -443,6 +447,15 @@ function cycleActionStatus(action: ActionItem) { } function actionStatusLabel(status: ActionStatus): string { + if (locale.value === 'en') { + const enMap: Record = { + open: 'Todo', + in_progress: 'Doing', + done: 'Done', + canceled: 'Canceled', + } + return enMap[status] || status + } const map: Record = { open: '待办', in_progress: '进行中', @@ -453,7 +466,7 @@ function actionStatusLabel(status: ActionStatus): string { } const noteActionTags = computed(() => { - const regex = /@行动:(\[[^\]]*\])*\s*([^\n\r]+)/g + const regex = /@(?:行动|Action|action):(\[[^\]]*\])*\s*([^\n\r]+)/g const matches: string[] = [] let match: RegExpExecArray | null while ((match = regex.exec(draft.notes_markdown)) !== null) { @@ -466,26 +479,26 @@ const noteActionTags = computed(() => {