You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
139 lines
5.2 KiB
139 lines
5.2 KiB
"""Prose synthesis for the interactive research flow: per-thread answers and |
|
the single board report. Both are plain-text markdown generations (no format=, |
|
so long quote-rich prose isn't squeezed through JSON escaping). |
|
|
|
Citation contract with the frontend: ``[källa:<talk_id>]`` — the same bare ids |
|
that power the finding source chips. A deterministic backstop (same philosophy |
|
as trip._ground) strips any marker whose id was not among the thread's grounded |
|
findings, so the model can never cite something it wasn't given. |
|
""" |
|
from __future__ import annotations |
|
|
|
import logging |
|
import os |
|
import re |
|
from typing import Iterable, List, Optional |
|
from prompts_loader import load_prompt |
|
|
|
log = logging.getLogger("riksdagen.research.synthesis") |
|
|
|
RESEARCH_ANSWER_MAX_TOKENS = int(os.getenv("RESEARCH_ANSWER_MAX_TOKENS", "2000")) |
|
RESEARCH_REPORT_MAX_TOKENS = int(os.getenv("RESEARCH_REPORT_MAX_TOKENS", "3500")) |
|
|
|
CITE_RE = re.compile(r"\[källa:\s*([\w\-]+)\s*\]") |
|
|
|
_ANSWER_SYSTEM = load_prompt("research/answer") |
|
|
|
_REPORT_SYSTEM = load_prompt("research/report") |
|
|
|
|
|
def ground_citations(text: str, allowed_ids: Iterable[str]) -> str: |
|
"""Strip [källa:ID] markers whose id isn't in ``allowed_ids`` (text kept).""" |
|
allowed = {i for i in allowed_ids if i} |
|
|
|
def _sub(m: re.Match) -> str: |
|
return m.group(0) if m.group(1) in allowed else "" |
|
|
|
return CITE_RE.sub(_sub, text) |
|
|
|
|
|
def _finding_lines(findings: List[dict]) -> List[str]: |
|
lines: List[str] = [] |
|
for f in findings: |
|
who = f.get("speaker") or "" |
|
if f.get("party"): |
|
who = f"{who} ({f['party']})" if who else f"({f['party']})" |
|
meta = " — ".join(x for x in (who, f.get("date") or "") if x) |
|
lines.append(f"- [{f.get('source_id') or '?'}] {f.get('label') or ''}") |
|
if f.get("detail"): |
|
lines.append(f" {f['detail']}") |
|
if f.get("quote"): |
|
lines.append(f" Citat ({meta or 'okänd'}): \"{f['quote']}\"") |
|
return lines |
|
|
|
|
|
def _generate_markdown(smart_llm, system: str, prompt: str, max_tokens: int, |
|
what: str) -> Optional[str]: |
|
try: |
|
res = smart_llm.generate( |
|
messages=[ |
|
{"role": "system", "content": system}, |
|
{"role": "user", "content": prompt}, |
|
], |
|
think=False, |
|
max_tokens=max_tokens, |
|
) |
|
except Exception: |
|
log.exception("synthesis: %s LLM call failed", what) |
|
return None |
|
if isinstance(res, str): |
|
# The LLM client returns a bare string on API errors — never persist it. |
|
log.warning("synthesis: %s LLM error: %s", what, res[:200]) |
|
return None |
|
content = (getattr(res, "content", None) or "").strip() |
|
return content or None |
|
|
|
|
|
def synthesize_thread_answer(smart_llm, thread: dict) -> Optional[str]: |
|
"""Detailed markdown answer to one thread's question, grounded in its |
|
findings. None when there is nothing to write or the LLM call failed.""" |
|
findings = list(thread.get("findings") or []) |
|
if not findings: |
|
return None |
|
lines: List[str] = [ |
|
f"TRÅD: {thread.get('title') or ''}", |
|
f"FRÅGA: {thread.get('question') or ''}", |
|
] |
|
if thread.get("guidance"): |
|
lines.append(f"ANVÄNDARENS MEDSKICK: {thread['guidance']}") |
|
lines += ["", "FYND (käll-id inom hakparentes):"] |
|
lines += _finding_lines(findings) |
|
questions = list(thread.get("open_questions") or []) |
|
if questions: |
|
lines += ["", "OBESVARADE FRÅGOR:"] |
|
lines += [f"- {q}" for q in questions[:8]] |
|
lines += ["", "Skriv det detaljerade svaret på trådens fråga."] |
|
|
|
answer = _generate_markdown( |
|
smart_llm, _ANSWER_SYSTEM, "\n".join(lines), |
|
RESEARCH_ANSWER_MAX_TOKENS, f"answer({thread.get('id')})", |
|
) |
|
if answer is None: |
|
return None |
|
return ground_citations(answer, (f.get("source_id") for f in findings)) |
|
|
|
|
|
def synthesize_report(smart_llm, board: dict, threads: List[dict]) -> Optional[str]: |
|
"""One coherent markdown report for the whole board, woven from the thread |
|
answers (findings digest as fallback for threads without one).""" |
|
sections: List[str] = [f"ÄMNE: {board.get('topic') or ''}"] |
|
if board.get("intro"): |
|
sections.append(f"INTRO: {board['intro']}") |
|
allowed_ids: List[str] = [] |
|
substance = False |
|
for t in threads: |
|
findings = list(t.get("findings") or []) |
|
answer = (t.get("answer") or "").strip() |
|
if not findings and not answer: |
|
continue |
|
substance = True |
|
allowed_ids += [f.get("source_id") or "" for f in findings] |
|
part = [f"## TRÅD: {t.get('title') or ''}", f"Fråga: {t.get('question') or ''}"] |
|
if answer: |
|
if len(answer) > 3000: |
|
answer = re.sub(r"\[källa:[^\]]*$", "", answer[:3000]) |
|
part += ["Svar:", answer] |
|
else: |
|
part += ["Fynd:"] + _finding_lines(findings) |
|
sections.append("\n".join(part)) |
|
if not substance: |
|
return None |
|
sections.append("Skriv den samlade rapporten.") |
|
|
|
report = _generate_markdown( |
|
smart_llm, _REPORT_SYSTEM, "\n\n".join(sections), |
|
RESEARCH_REPORT_MAX_TOKENS, f"report({board.get('id')})", |
|
) |
|
if report is None: |
|
return None |
|
return ground_citations(report, allowed_ids)
|
|
|