Artifex/control_plane/authoring/scene_context.py

388 lines
12 KiB
Python
Raw Normal View History

from __future__ import annotations
import re
from dataclasses import dataclass
from django.db.models import Q
from control_plane.authoring.models import (
DocumentAuthority,
SourceDocumentVersion,
SourcePassage,
Work,
WorkType,
)
from control_plane.authoring.state_management import json_sha256
STOP_WORDS = {
"and",
"are",
"about",
"after",
"again",
"also",
"before",
"being",
"between",
"but",
"could",
"for",
"from",
"has",
"her",
"him",
"his",
"have",
"into",
"its",
"must",
"not",
"scene",
"she",
"should",
"that",
"the",
"their",
"them",
"then",
"there",
"they",
"this",
"through",
"what",
"when",
"where",
"which",
"while",
"with",
"would",
"was",
"were",
"write",
}
@dataclass(frozen=True)
class RankedPassage:
passage: SourcePassage
score: float
reason: str
def query_terms(query: str, limit: int = 16) -> list[str]:
counts: dict[str, int] = {}
for token in re.findall(r"[a-zA-Z][a-zA-Z0-9']{2,}", query.lower()):
if token in STOP_WORDS:
continue
counts[token] = counts.get(token, 0) + 1
ordered = sorted(
counts.items(), key=lambda item: (-item[1], -len(item[0]), item[0])
)
return [token for token, _count in ordered[:limit]]
def retrieve_scene_passages(
*,
work: Work,
query: str,
authorities: list[str] | None = None,
pinned_document_keys: list[str] | None = None,
limit: int = 24,
) -> list[RankedPassage]:
authorities = authorities or [DocumentAuthority.CANON]
invalid = sorted(set(authorities) - set(DocumentAuthority.values))
if invalid:
raise ValueError(f"unsupported document authorities: {', '.join(invalid)}")
pinned = {value.strip() for value in (pinned_document_keys or []) if value.strip()}
terms = query_terms(query)
visible_work_ids = list(
Work.objects.filter(series=work.series, work_type=WorkType.SERIES_REFERENCE).values_list(
"id", flat=True
)
)
visible_work_ids.append(work.id)
base = SourcePassage.objects.select_related(
"document_version__document"
).filter(
document_version__document__work_id__in=visible_work_ids,
document_version__authority__in=authorities,
document_version__superseded_by__isnull=True,
)
candidates: dict[object, SourcePassage] = {}
if terms:
term_filter = Q()
for term in terms:
term_filter |= Q(content__icontains=term)
term_filter |= Q(document_version__document__title__icontains=term)
for passage in base.filter(term_filter)[:4000]:
candidates[passage.id] = passage
if pinned:
for passage in base.filter(document_version__document__logical_key__in=pinned)[:2000]:
candidates[passage.id] = passage
if not candidates:
for passage in base[:300]:
candidates[passage.id] = passage
ranked: list[RankedPassage] = []
lowered_query = query.lower()
for passage in candidates.values():
document = passage.document_version.document
haystack = passage.content.lower()
identity = f"{document.logical_key} {document.title}".lower()
score = 0.0
matched = []
for term in terms:
occurrences = haystack.count(term)
if occurrences:
score += 1.0 + min(occurrences, 4) * 0.5
matched.append(term)
if term in identity:
score += 4.0
if document.logical_key in pinned:
score += 100.0
if passage.document_version.authority == DocumentAuthority.CANON:
score += 2.0
if document.title.lower() in lowered_query:
score += 5.0
reason = "pinned" if document.logical_key in pinned else "terms: " + ", ".join(matched[:6])
ranked.append(RankedPassage(passage=passage, score=score, reason=reason.strip()))
ranked.sort(
key=lambda item: (
-item.score,
item.passage.document_version.document.logical_key,
item.passage.ordinal,
)
)
requested_limit = max(1, limit)
if not pinned:
if len(authorities) == 1:
return ranked[:requested_limit]
return _select_across_authorities(ranked, authorities, requested_limit)
pinned_ranked = [
item
for item in ranked
if item.passage.document_version.document.logical_key in pinned
]
other_ranked = [
item
for item in ranked
if item.passage.document_version.document.logical_key not in pinned
]
if not other_ranked:
return ranked[:requested_limit]
pinned_budget = min(len(pinned_ranked), max(1, requested_limit * 2 // 3))
found_pinned_keys = {
item.passage.document_version.document.logical_key for item in pinned_ranked
}
per_document_limit = max(
1,
(pinned_budget + len(found_pinned_keys) - 1) // max(1, len(found_pinned_keys)),
)
selected: list[RankedPassage] = []
pinned_counts: dict[str, int] = {}
for item in pinned_ranked:
document_key = item.passage.document_version.document.logical_key
if len(selected) >= pinned_budget:
break
if pinned_counts.get(document_key, 0) >= per_document_limit:
continue
selected.append(item)
pinned_counts[document_key] = pinned_counts.get(document_key, 0) + 1
selected.extend(
_select_across_authorities(
other_ranked,
authorities,
requested_limit - len(selected),
)
)
selected_ids = {item.passage.id for item in selected}
for item in ranked:
if len(selected) >= requested_limit:
break
if item.passage.id not in selected_ids:
selected.append(item)
selected_ids.add(item.passage.id)
return selected
def _select_across_authorities(
ranked: list[RankedPassage], authorities: list[str], limit: int
) -> list[RankedPassage]:
authority_groups = {
authority: [
item
for item in ranked
if item.passage.document_version.authority == authority
]
for authority in authorities
}
offsets = {authority: 0 for authority in authorities}
selected: list[RankedPassage] = []
while len(selected) < limit:
added = False
for authority in authorities:
offset = offsets[authority]
group = authority_groups[authority]
if offset >= len(group):
continue
selected.append(group[offset])
offsets[authority] += 1
added = True
if len(selected) >= limit:
break
if not added:
break
selected_ids = {item.passage.id for item in selected}
for item in ranked:
if len(selected) >= limit:
break
if item.passage.id not in selected_ids:
selected.append(item)
selected_ids.add(item.passage.id)
return selected
def build_scene_context_pack(
*,
work: Work,
query: str,
authorities: list[str] | None = None,
pinned_document_keys: list[str] | None = None,
governing_document_keys: list[str] | None = None,
limit: int = 24,
max_chars: int = 50000,
) -> tuple[dict, list[RankedPassage]]:
authorities = authorities or [DocumentAuthority.CANON]
governing_keys = list(
dict.fromkeys(
value.strip() for value in (governing_document_keys or []) if value.strip()
)
)
ranked = retrieve_scene_passages(
work=work,
query=query,
authorities=authorities,
pinned_document_keys=pinned_document_keys,
limit=limit,
)
citations = []
rendered = []
used_chars = 0
kept: list[RankedPassage] = []
if governing_keys:
visible_work_ids = list(
Work.objects.filter(
series=work.series,
work_type=WorkType.SERIES_REFERENCE,
).values_list("id", flat=True)
)
visible_work_ids.append(work.id)
versions = list(
SourceDocumentVersion.objects.select_related("document")
.filter(
document__work_id__in=visible_work_ids,
document__logical_key__in=governing_keys,
authority__in=authorities,
superseded_by__isnull=True,
)
.order_by("document__logical_key")
)
versions_by_key: dict[str, list[SourceDocumentVersion]] = {}
for version in versions:
versions_by_key.setdefault(version.document.logical_key, []).append(version)
missing = [key for key in governing_keys if key not in versions_by_key]
ambiguous = [key for key, values in versions_by_key.items() if len(values) > 1]
if missing:
raise ValueError("governing documents not found: " + ", ".join(missing))
if ambiguous:
raise ValueError("governing document keys are ambiguous: " + ", ".join(ambiguous))
for key in governing_keys:
version = versions_by_key[key][0]
document = version.document
label = f"SRC-{len(citations) + 1:02d}"
end_line = version.content.count("\n") + 1
block = (
f"[{label}] authority={version.authority} source={document.logical_key} "
f"version={version.version} lines=1-{end_line} scope=governing-document\n"
f"{version.content}"
)
if used_chars + len(block) > max_chars:
raise ValueError("governing documents exceed the context character budget")
used_chars += len(block)
rendered.append(block)
citations.append(
{
"id": label,
"kind": "governing_document",
"passage_id": None,
"document_version_id": str(version.id),
"document_key": document.logical_key,
"document_title": document.title,
"document_version": version.version,
"authority": version.authority,
"source_path": version.source_path,
"start_line": 1,
"end_line": end_line,
"start_char": 0,
"end_char": len(version.content),
"sha256": version.source_sha256,
"score": None,
"reason": "governing document supplied in full",
}
)
governing_set = set(governing_keys)
for item in ranked:
passage = item.passage
version = passage.document_version
document = version.document
if document.logical_key in governing_set:
continue
excerpt = passage.content[:2500]
label = f"SRC-{len(citations) + 1:02d}"
block = (
f"[{label}] authority={version.authority} source={document.logical_key} "
f"version={version.version} lines={passage.start_line}-{passage.end_line}\n{excerpt}"
)
if rendered and used_chars + len(block) > max_chars:
continue
used_chars += len(block)
kept.append(item)
citations.append(
{
"id": label,
"passage_id": str(passage.id),
"document_key": document.logical_key,
"document_title": document.title,
"document_version": version.version,
"authority": version.authority,
"source_path": version.source_path,
"start_line": passage.start_line,
"end_line": passage.end_line,
"start_char": passage.start_char,
"end_char": passage.end_char,
"sha256": passage.sha256,
"score": item.score,
"reason": item.reason,
}
)
rendered.append(block)
pack = {
"schema_version": 1,
"work_id": str(work.id),
"query": query,
"authorities": authorities,
"governing_document_keys": governing_keys,
"citations": citations,
"rendered_context": (
"\n\n".join(rendered)
if rendered
else "(No matching approved source passages.)"
),
}
pack["sha256"] = json_sha256(pack)
return pack, kept