Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 124 additions & 3 deletions backend/app/agendas/outcome_tags.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
from __future__ import annotations

import re
from dataclasses import dataclass
from datetime import date
from typing import Literal

from app.domain.enums import ActionStatus
from app.errors import AppError


OutcomeKind = Literal["decision", "action", "question"]

_BRACKET_RE = re.compile(r"^\[([^\]]+)\]")
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


@dataclass(frozen=True)
class TaggedOutcome:
kind: OutcomeKind
content: str
source_tag_key: str
status: ActionStatus = ActionStatus.open
owner_name: str | None = None
due_date: date | None = None


_TAGS: tuple[tuple[OutcomeKind, str], ...] = (
Expand All @@ -23,8 +32,100 @@ class TaggedOutcome:
)


def parse_action_metadata(
raw_payload: str,
) -> tuple[str, ActionStatus, str | None, date | None]:
"""Parse [status][@owner][due_date] from the beginning of raw action content."""
text = raw_payload.strip()
status = ActionStatus.open
owner: str | None = None
due_date_val: date | None = None

while True:
text = text.strip()
match = _BRACKET_RE.match(text)
if not match:
break
token = match.group(1).strip()
text = text[match.end() :]

if token.startswith("@"):
owner = token[1:].strip()
elif _DATE_RE.match(token):
try:
due_date_val = date.fromisoformat(token)
except ValueError:
pass
elif token.lower() in ("todo", "open"):
status = ActionStatus.open
elif token.lower() in ("in_progress", "doing", "progress"):
status = ActionStatus.in_progress
elif token.lower() in ("done", "completed", "finish", "finished"):
status = ActionStatus.done
elif token.lower() in ("canceled", "cancelled"):
status = ActionStatus.canceled

return text.strip(), status, owner, due_date_val


def format_action_tag(
*,
content: str,
status: ActionStatus | str = ActionStatus.open,
owner: str | None = None,
due_date: date | str | None = None,
) -> str:
"""Format an ActionItem into the tag syntax @行动:[status][@owner][due_date] content."""
status_str = status.value if hasattr(status, "value") else str(status)
status_token = (
"done"
if status_str == "done"
else (
"in_progress"
if status_str == "in_progress"
else ("canceled" if status_str == "canceled" else "todo")
)
)
parts = [f"[{status_token}]"]
if owner:
clean_owner = owner.lstrip("@").strip()
if clean_owner:
parts.append(f"[@{clean_owner}]")
if due_date:
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()}"


def rewrite_action_in_notes(
notes_markdown: str,
source_tag_key: str,
new_tag_line: str,
) -> str:
"""Rewrite the line corresponding to source_tag_key in notes_markdown."""
try:
target_index = int(source_tag_key.split(":", 1)[1])
except (IndexError, ValueError):
return notes_markdown

lines = notes_markdown.splitlines(keepends=True)
action_count = 0
for idx, raw_line in enumerate(lines):
line = raw_line.strip()
if line.startswith("@行动:"):
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}"
return "".join(lines)
action_count += 1
return notes_markdown


def parse_outcome_tags(markdown: str) -> list[TaggedOutcome]:
"""Parse the only three supported, one-per-line agenda outcome tags."""
"""Parse the supported, one-per-line agenda outcome tags."""
counters: dict[OutcomeKind, int] = {
"decision": 0,
"action": 0,
Expand All @@ -40,21 +141,41 @@ def parse_outcome_tags(markdown: str) -> list[TaggedOutcome]:
if matched is None:
continue
kind, prefix = matched
content = line[len(prefix) :].strip()
if not content or any(tag in content for _, tag in _TAGS):
raw_payload = line[len(prefix) :].strip()
if not raw_payload or any(tag in raw_payload for _, tag in _TAGS):
raise AppError(
422,
"invalid_agenda_outcome_tag",
"议题成果标签必须每行填写一项非空内容",
details={"line": line_number},
)

status = ActionStatus.open
owner_name = None
due_date_val = None
if kind == "action":
content, status, owner_name, due_date_val = parse_action_metadata(raw_payload)
if not content or any(tag in content for _, tag in _TAGS):
raise AppError(
422,
"invalid_agenda_outcome_tag",
"议题成果标签必须每行填写一项非空内容",
details={"line": line_number},
)
else:
content = raw_payload

source_tag_key = f"{kind}:{counters[kind]}"
counters[kind] += 1
result.append(
TaggedOutcome(
kind=kind,
content=content,
source_tag_key=source_tag_key,
status=status,
owner_name=owner_name,
due_date=due_date_val,
)
)
return result

37 changes: 36 additions & 1 deletion backend/app/agendas/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
)
from app.auth.models import User, UserStatus
from app.collaboration.activity import ActivityRecorder
from app.domain.enums import AgendaStatus, MeetingStatus, OpenQuestionStatus
from app.domain.enums import ActionStatus, AgendaStatus, MeetingStatus, OpenQuestionStatus
from app.domain.versioning import StaleCheck, require_version, resolve_stale
from app.errors import AppError
from app.meetings.models import Meeting
Expand Down Expand Up @@ -73,6 +73,18 @@ def get(self, item_id: str) -> AgendaItem:
raise AppError(404, "agenda_item_not_found", "议题不存在")
return item

def _resolve_owner_identifier(self, identifier: str | None) -> str | None:
if not identifier:
return None
user = self.session.scalar(
select(User).where(
(User.username == identifier)
| (User.display_name == identifier)
| (User.id == identifier)
)
)
return user.id if user else None

def _reconcile_derived_outcomes(
self, item: AgendaItem, tags: list[TaggedOutcome], actor: User
) -> None:
Expand Down Expand Up @@ -122,6 +134,7 @@ def _reconcile_derived_outcomes(
)
)
elif tag.kind == "action":
owner_id = self._resolve_owner_identifier(tag.owner_name)
self.session.add(
ActionItem(
project_id=meeting.project_id,
Expand All @@ -130,6 +143,10 @@ def _reconcile_derived_outcomes(
source_agenda_item_id=item.id,
source_tag_key=tag.source_tag_key,
content=tag.content,
status=tag.status,
owner_user_id=owner_id,
due_date=tag.due_date,
completed_at=utcnow() if tag.status == ActionStatus.done else None,
created_by=actor.id,
)
)
Expand Down Expand Up @@ -165,6 +182,24 @@ def _reconcile_derived_outcomes(
if getattr(row, field) != value:
setattr(row, field, value)
changed = True

if tag.kind == "action":
if tag.owner_name:
resolved_owner = self._resolve_owner_identifier(tag.owner_name)
if row.owner_user_id != resolved_owner:
row.owner_user_id = resolved_owner
changed = True
if tag.due_date is not None and row.due_date != tag.due_date:
row.due_date = tag.due_date
changed = True
if tag.status != row.status:
row.status = tag.status
if tag.status == ActionStatus.done and not row.completed_at:
row.completed_at = utcnow()
elif tag.status != ActionStatus.done and row.completed_at:
row.completed_at = None
changed = True

if changed:
row.version += 1

Expand Down
Loading
Loading