Rifqi Hafizuddin
[NOTICKET] fix(knowledge_extraction): show the model the section heading
a7bdeae
Raw
History Blame
2.65 kB
"""Shared plumbing for the four extraction branches.
Prompts live in `prompts/*.txt`, never in code, for two reasons: a prompt change
is not a code change, and **the fixed prefix must stay byte-identical across
calls** or prompt caching silently stops engaging at roughly 10x the input cost.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from ..models import Chunk
from ..settings import CACHE_MIN_TOKENS
PROMPT_DIR = Path(__file__).parent / "prompts"
def est_tokens(text: str) -> int:
"""Cheap estimate, for dry-run budgeting only. Real counts come from the
API's usage object — never report a cached price from an estimate."""
return max(1, int(len(text) / 3.6))
@lru_cache(maxsize=8)
def load_prompt(branch: str) -> str:
return (PROMPT_DIR / f"{branch}.txt").read_text(encoding="utf-8")
def prefix_tokens(branch: str) -> int:
return est_tokens(load_prompt(branch))
def cacheable(branch: str) -> bool:
"""Whether the fixed prefix is long enough to cache at all.
Reported, never assumed: caching does not engage below the floor, so a
shorter prefix caches nothing. Only the API's `cached_tokens` proves a hit.
"""
return prefix_tokens(branch) >= CACHE_MIN_TOKENS
def evidence_block(chunks: list[Chunk], scores: list[float] | None = None) -> str:
"""Evidence labelled with chunk_id, section and page so the model can cite
provenance and we can trace which evidence produced which field.
**The heading is included, and must stay included.** Two reasons:
1. Indonesian standards name the term in the heading and open the body with
the definition — "2.1.3 Physical of Availability (PA)" / "Adalah
ketersediaan fisik…" — so the body often never repeats the term. Without
the heading the model is asked to define a term the evidence never names.
2. It keeps one invariant true: **what the model reads is exactly what the
span check searches.** `validate.evidence_text` composes heading + text;
if this block showed only the text, the model could never quote a
section title, and any field that did quote one would be rejected as
unlocatable.
"""
parts = []
for i, chunk in enumerate(chunks):
score = f" score={scores[i]:.1f}" if scores and i < len(scores) else ""
head = f"{chunk.heading}\n" if chunk.heading else ""
parts.append(
f"[chunk_id={chunk.chunk_id} section={chunk.section_no or '-'} "
f"page={chunk.page_start}{score}]\n{head}{chunk.text}"
)
return "EVIDENCE\n" + "\n\n---\n\n".join(parts)