|
|
@@ -27,6 +27,7 @@ Public API (consumed by the assistant service layer):
|
|
|
def parse_ledger_reply(self, text, truncated=False) -> tuple[str, list[str]] | None
|
|
|
def parse_facts(self, block) -> list[str]
|
|
|
def should_update_brief(self, signals) -> bool
|
|
|
+ def is_regression(self, prior_brief, new_brief) -> bool
|
|
|
def finalize_brief(self, text, finish_reason) -> str | None
|
|
|
def state(self) -> tuple[str, str]
|
|
|
def refresh(self, state: str, reason: str = "") -> None
|
|
|
@@ -59,6 +60,21 @@ TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing", "disabled", "
|
|
|
|
|
|
_VALID_TYPES = ("choice", "score", "noul")
|
|
|
|
|
|
+# Canonical brief sections in model emission order; regression detection and the
|
|
|
+# reply template both depend on this exact list.
|
|
|
+BRIEF_HEADINGS = (
|
|
|
+ "Summary:",
|
|
|
+ "Opportunities:",
|
|
|
+ "Blockers/Risks:",
|
|
|
+ "Responsibilities:",
|
|
|
+ "Decisions:",
|
|
|
+ "Action Items:",
|
|
|
+ "Deadlines:",
|
|
|
+ "Open Questions:",
|
|
|
+)
|
|
|
+
|
|
|
+BRIEF_SECTION_TEMPLATE = "\n".join(BRIEF_HEADINGS)
|
|
|
+
|
|
|
|
|
|
def _invalid(path, qid, why):
|
|
|
logger.warning("ignoring questions file %r: question %r: %s", path, qid, why)
|
|
|
@@ -420,6 +436,27 @@ class TriageAnalyzer:
|
|
|
except (TypeError, ValueError):
|
|
|
return 0.5
|
|
|
|
|
|
+ def _clip_for_prompt(self, text: str) -> tuple[str, bool]:
|
|
|
+ """Trim ``text`` to the brief budget at a line or word boundary.
|
|
|
+
|
|
|
+ Returns ``(text, trimmed)``. The stored brief is never clipped; only the
|
|
|
+ view handed to the model is bounded, so a long-running meeting cannot
|
|
|
+ lose sections to a mid-word cut. The line boundary is used only when it
|
|
|
+ keeps at least half the budget, because a single long Summary paragraph
|
|
|
+ is a normal brief shape.
|
|
|
+ """
|
|
|
+ limit = self._brief_max_chars()
|
|
|
+ if len(text) <= limit:
|
|
|
+ return text, False
|
|
|
+ cut = text.rfind("\n", 0, limit)
|
|
|
+ if cut < limit // 2:
|
|
|
+ space = text.rfind(" ", 0, limit)
|
|
|
+ if space > cut:
|
|
|
+ cut = space
|
|
|
+ if cut <= 0:
|
|
|
+ cut = limit
|
|
|
+ return text[:cut].rstrip(), True
|
|
|
+
|
|
|
def _signal_lines(self, signals) -> list[str]:
|
|
|
"""Render each ordered signal as a "- " words line; never raises.
|
|
|
|
|
|
@@ -489,37 +526,46 @@ class TriageAnalyzer:
|
|
|
current ledger (facts it must NOT repeat) and asked for the two halves
|
|
|
back in fixed machine-parseable sections. ``NEW FACTS:`` is placed first
|
|
|
so a length cutoff loses narrative (regenerated every pass) rather than a
|
|
|
- durable fact.
|
|
|
+ durable fact. The narrative is bounded here, at a line or word boundary,
|
|
|
+ and never in the accepted text.
|
|
|
"""
|
|
|
try:
|
|
|
body = "\n".join("- " + delta for delta in (deltas or []))
|
|
|
except Exception:
|
|
|
body = ""
|
|
|
ledger_text = "\n".join("- " + fact for fact in (ledger or [])) or "(none yet)"
|
|
|
+ current, trimmed = self._clip_for_prompt(narrative or "")
|
|
|
parts = [
|
|
|
"CURRENT LEDGER (facts already recorded - never repeat these):\n" + ledger_text,
|
|
|
- "CURRENT BRIEF:\n" + (narrative or "(none yet)"),
|
|
|
+ "CURRENT BRIEF:\n" + (current or "(none yet)"),
|
|
|
"NEW UTTERANCES:\n" + body,
|
|
|
]
|
|
|
focus = self._signal_lines(signals)
|
|
|
if focus:
|
|
|
parts.append("Focus:\n" + "\n".join(focus))
|
|
|
- parts.append(
|
|
|
+ instructions = [
|
|
|
"Reply with exactly these two sections and nothing else:\n"
|
|
|
"NEW FACTS:\n"
|
|
|
"- only genuinely new durable facts, one per line; write 'none' if there are none\n"
|
|
|
"BRIEF:\n"
|
|
|
- "Summary:\n...\nOpportunities:\n...\nBlockers/Risks:\n...\n"
|
|
|
- "Decisions:\n...\nOpen Questions:\n..."
|
|
|
- "\nNever silently delete Action Items, Open Questions, or Decisions "
|
|
|
+ + BRIEF_SECTION_TEMPLATE,
|
|
|
+ "Never silently delete Action Items, Open Questions, or Decisions "
|
|
|
"lines. When an action completes or a question is answered, move it "
|
|
|
"to Decisions as 'Done: <original> — resolved: <what happened>' "
|
|
|
"(or keep it with a 'Done (...)' suffix) and record the resolution "
|
|
|
"as a NEW FACTS bullet; prune only exact duplicates."
|
|
|
" Lead the Summary with currently-open opportunities first, and keep "
|
|
|
"the Opportunities section the most complete and current without "
|
|
|
- "dropping other sections."
|
|
|
- )
|
|
|
+ "dropping other sections. Emit every section heading above, even when "
|
|
|
+ "a section is empty.",
|
|
|
+ ]
|
|
|
+ if trimmed:
|
|
|
+ instructions.append(
|
|
|
+ "The CURRENT BRIEF was trimmed to fit this request. Keep every "
|
|
|
+ "existing section heading, merge duplicates, and shorten prose so "
|
|
|
+ "the whole brief fits again."
|
|
|
+ )
|
|
|
+ parts.append("\n".join(instructions))
|
|
|
return "\n\n".join(parts)
|
|
|
|
|
|
def parse_ledger_reply(self, text, truncated: bool = False) -> tuple[str, list[str]] | None:
|
|
|
@@ -618,6 +664,21 @@ class TriageAnalyzer:
|
|
|
keys.append(key)
|
|
|
return keys
|
|
|
|
|
|
+ def _budget_bullets(self, bullets: list[str]) -> str:
|
|
|
+ """Render ``- `` bullets within the brief budget, keeping the newest."""
|
|
|
+ budget = self._brief_max_chars()
|
|
|
+ kept: list[str] = []
|
|
|
+ used = 0
|
|
|
+ for bullet in reversed(bullets):
|
|
|
+ line = "- " + bullet
|
|
|
+ cost = len(line) + 1
|
|
|
+ if kept and used + cost > budget:
|
|
|
+ break
|
|
|
+ kept.append(line)
|
|
|
+ used += cost
|
|
|
+ kept.reverse()
|
|
|
+ return "\n".join(kept)
|
|
|
+
|
|
|
def restore_missing_bullets(self, new_brief, prior_brief) -> str:
|
|
|
"""Re-append any prior bullet that the model dropped from ``new_brief``.
|
|
|
|
|
|
@@ -626,7 +687,8 @@ class TriageAnalyzer:
|
|
|
the model re-emitted the document without it. Bullets the new brief merely
|
|
|
reworded still match by normalized key, so only a genuinely absent bullet
|
|
|
is restored. Restoration appends under the brief's final ``Prior
|
|
|
- Details:`` section, or at the end when absent.
|
|
|
+ Details:`` section, which is bounded by ``brief_max_chars`` and keeps the
|
|
|
+ newest bullets when it overflows.
|
|
|
"""
|
|
|
if not isinstance(new_brief, str) or not new_brief.strip():
|
|
|
return new_brief
|
|
|
@@ -645,12 +707,20 @@ class TriageAnalyzer:
|
|
|
missing.append(stripped[2:].strip())
|
|
|
if not missing:
|
|
|
return new_brief
|
|
|
- restored = "\n".join("- " + bullet for bullet in missing)
|
|
|
marker = "Prior Details:"
|
|
|
if marker in new_brief:
|
|
|
head, _, tail = new_brief.rpartition(marker)
|
|
|
- return f"{head}{marker}{tail.rstrip()}\n{restored}"
|
|
|
- return f"{new_brief.rstrip()}\n\nPrior Details:\n{restored}"
|
|
|
+ base = head + marker
|
|
|
+ carried = [
|
|
|
+ line.strip()[2:].strip()
|
|
|
+ for line in tail.splitlines()
|
|
|
+ if line.strip().startswith("- ")
|
|
|
+ ]
|
|
|
+ else:
|
|
|
+ base = new_brief.rstrip() + "\n\n" + marker
|
|
|
+ carried = []
|
|
|
+ block = self._budget_bullets(carried + missing)
|
|
|
+ return f"{base.rstrip()}\n{block}"
|
|
|
|
|
|
@staticmethod
|
|
|
def _first_arg_start(text: str) -> int:
|
|
|
@@ -753,10 +823,6 @@ class TriageAnalyzer:
|
|
|
)
|
|
|
if not cleaned:
|
|
|
return None
|
|
|
- limit = self._brief_max_chars()
|
|
|
- if len(cleaned) > limit:
|
|
|
- logger.warning("truncating brief from %d to %d chars", len(cleaned), limit)
|
|
|
- cleaned = cleaned[:limit]
|
|
|
return cleaned
|
|
|
|
|
|
def should_update_brief(self, signals) -> bool:
|
|
|
@@ -774,10 +840,27 @@ class TriageAnalyzer:
|
|
|
return True
|
|
|
return prob >= self._brief_gate_threshold()
|
|
|
|
|
|
+ def is_regression(self, prior_brief, new_brief) -> bool:
|
|
|
+ """True when ``new_brief`` lost a canonical section the prior one had.
|
|
|
+
|
|
|
+ Guards against accepting a rewrite that silently dropped whole sections:
|
|
|
+ the previous brief stays authoritative instead of being replaced by a
|
|
|
+ shorter, structurally poorer document.
|
|
|
+ """
|
|
|
+ if not isinstance(prior_brief, str) or not isinstance(new_brief, str):
|
|
|
+ return False
|
|
|
+ if not prior_brief.strip() or not new_brief.strip():
|
|
|
+ return False
|
|
|
+ for heading in BRIEF_HEADINGS:
|
|
|
+ if heading in prior_brief and heading not in new_brief:
|
|
|
+ return True
|
|
|
+ return False
|
|
|
+
|
|
|
def finalize_brief(self, text, finish_reason) -> str | None:
|
|
|
- """Accept any parseable brief; a length cutoff truncates, never rejects."""
|
|
|
+ """Parse a complete brief; a length cutoff rejects the whole reply."""
|
|
|
if finish_reason == "length":
|
|
|
- logger.warning("brief reply hit the length limit; keeping the truncated text")
|
|
|
+ logger.warning("brief reply hit the length limit; rejecting the truncated text")
|
|
|
+ return None
|
|
|
return self.parse_brief(text)
|
|
|
|
|
|
def state(self) -> tuple[str, str]:
|