fix: preserve source task sections and explicit empty notes

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-10 15:11:03 +02:00
parent ccb285fc40
commit f767a1a245
4 changed files with 231 additions and 17 deletions

View file

@ -30,7 +30,7 @@ from api.services.task_record_id_backfill import qualify_task_id
_WORK_RECORD_NAMESPACE = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009")
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE)
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)$", re.MULTILINE)
DEFAULT_FORGE_BASE = "https://forgejo.coulomb.social/coulomb"
@ -272,12 +272,23 @@ def _split_frontmatter(text: str) -> tuple[dict, str]:
return (meta if isinstance(meta, dict) else {}), body
def _clean_task_description(raw: str) -> str | None:
"""Match the file-authoritative consistency parser's section boundaries."""
lines = raw.splitlines()
while lines and (not lines[0].strip() or lines[0].strip() in {"---", "***", "___"}):
lines.pop(0)
while lines and (not lines[-1].strip() or lines[-1].strip() in {"---", "***", "___"}):
lines.pop()
return "\n".join(lines).strip() or None
def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
headings = [
(m.start(), m.group(2).strip()) for m in _HEADING_RE.finditer(body)
(m.start(), len(m.group(1)), m.group(2).strip()) for m in _HEADING_RE.finditer(body)
]
out: list[DerivedTask] = []
for m in _TASK_BLOCK_RE.finditer(body):
task_matches = list(_TASK_BLOCK_RE.finditer(body))
for index, m in enumerate(task_matches):
try:
block = yaml.safe_load(m.group(1).strip()) or {}
except yaml.YAMLError:
@ -287,15 +298,21 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
rid = str(block.get("id") or "").strip()
if not rid:
continue
title = block.get("title")
if not title:
prev = [t for pos, t in headings if pos < m.start()]
title = prev[-1] if prev else None
following_headings = [pos for pos, _title in headings if pos > m.end()]
description_end = min(following_headings) if following_headings else len(body)
description = str(block.get("description") or "").strip()
if not description:
description = body[m.end() : description_end].strip()
prev = [(pos, level, text) for pos, level, text in headings if pos < m.start()]
heading = prev[-1] if prev else None
title = block.get("title") or (heading[2] if heading else None)
# Nested evidence belongs to this task. Stop at the next peer/ancestor
# heading or task block, as the ordinary consistency path does.
description_end = task_matches[index + 1].start() if index + 1 < len(task_matches) else len(body)
following_heading = next((
pos for pos, level, _text in headings
if pos > m.end() and (heading is None or level <= heading[1])
), None)
if following_heading is not None:
description_end = min(description_end, following_heading)
description = _clean_task_description(body[m.end():description_end])
if description is None and block.get("description") is not None:
description = str(block["description"])
out.append(
DerivedTask(
# A bare `T01` is not an identifier: it is unique only within
@ -315,16 +332,16 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
title=title,
status=(str(block["status"]).strip() if block.get("status") else None),
priority=(str(block["priority"]).strip() if block.get("priority") else None),
description=description or None,
description=description,
needs_human=bool(block.get("needs_human", False)),
intervention_note=(
str(block["intervention_note"]).strip()
if block.get("intervention_note")
str(block["intervention_note"])
if block.get("intervention_note") is not None
else None
),
blocking_reason=(
str(block["blocking_reason"]).strip()
if block.get("blocking_reason")
str(block["blocking_reason"])
if block.get("blocking_reason") is not None
else None
),
)