Compare commits
No commits in common. "6acc587dd5c99193977761e01667cd8e6827ffc6" and "c1260cfa68dd6dcad9b8dd91d3f446c0e44948f9" have entirely different histories.
6acc587dd5
...
c1260cfa68
3 changed files with 13 additions and 73 deletions
|
|
@ -16,7 +16,7 @@ import { audit } from "../lib/audit";
|
||||||
import { callClaude } from "../lib/claude";
|
import { callClaude } from "../lib/claude";
|
||||||
import { env } from "../lib/env";
|
import { env } from "../lib/env";
|
||||||
import { query } from "../lib/pg";
|
import { query } from "../lib/pg";
|
||||||
import { fetchDocChunks, hybridSearch, type SearchHit } from "../lib/search";
|
import { hybridSearch, type SearchHit } from "../lib/search";
|
||||||
import { writeCaseReport } from "../tools/write_case_report";
|
import { writeCaseReport } from "../tools/write_case_report";
|
||||||
|
|
||||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -286,27 +286,18 @@ export async function runCaseWriter(task: CaseWriterTask): Promise<
|
||||||
|
|
||||||
const filter = `%${topic.toLowerCase()}%`;
|
const filter = `%${topic.toLowerCase()}%`;
|
||||||
|
|
||||||
// Grounding pass — assemble the scenes the narrator weaves from.
|
// Grounding pass — retrieve top scenes from the corpus via hybrid_search.
|
||||||
//
|
// This is what gives the narrator real verbatim material to weave. Without
|
||||||
// For a per-document case file (doc_id set) we pull THIS document's own
|
// this, the case-writer only sees pre-digested artefacts (which is what
|
||||||
// chunks in reading order. A hybridSearch keyed on the document's
|
// produced the academic prose in v1).
|
||||||
// auto-derived topic ("Fbi Photo B20", "Doc 59 214434 …") returns zero
|
const scenes = await hybridSearch({
|
||||||
// hits even though the doc has dozens of embedded chunks — the dense gate
|
|
||||||
// rejects them all because the garbage topic has no semantic neighbours.
|
|
||||||
// That single bug skipped 61 of 75 batch documents on its own.
|
|
||||||
//
|
|
||||||
// For a corpus-wide topic report (no doc_id) the semantic search is
|
|
||||||
// exactly right — we want the strongest chunks across all documents.
|
|
||||||
const docIdFilter = task.doc_id ?? null;
|
|
||||||
const scenes = docIdFilter
|
|
||||||
? await fetchDocChunks(docIdFilter, lang, 24).catch(() => [] as SearchHit[])
|
|
||||||
: await hybridSearch({
|
|
||||||
query: topic, lang,
|
query: topic, lang,
|
||||||
doc_id: null,
|
doc_id: task.doc_id ?? null,
|
||||||
top_k: 18,
|
top_k: 18,
|
||||||
recall_k: 80,
|
recall_k: 80,
|
||||||
max_dense_dist: 0.55,
|
max_dense_dist: 0.55,
|
||||||
}).catch(() => [] as SearchHit[]);
|
}).catch(() => [] as SearchHit[]);
|
||||||
|
const docIdFilter = task.doc_id ?? null;
|
||||||
|
|
||||||
// Pull artefacts SEQUENTIALLY. The investigator role has rolconnlimit=4 and
|
// Pull artefacts SEQUENTIALLY. The investigator role has rolconnlimit=4 and
|
||||||
// pool.max=4; Promise.all of 5 queries × max_parallel=2 jobs would demand
|
// pool.max=4; Promise.all of 5 queries × max_parallel=2 jobs would demand
|
||||||
|
|
|
||||||
|
|
@ -67,50 +67,6 @@ export interface HybridSearchOpts {
|
||||||
max_dense_dist?: number;
|
max_dense_dist?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a single document's own chunks — no semantic gating. For a
|
|
||||||
* per-document case file the narrator wants THIS document's substance, not
|
|
||||||
* a corpus search; a hybridSearch keyed on the document's (often garbage)
|
|
||||||
* auto-derived topic returns zero hits even though the doc has dozens of
|
|
||||||
* embedded chunks.
|
|
||||||
*
|
|
||||||
* We pick the most substantive chunks (by content length, deprioritising
|
|
||||||
* pure redaction boxes) and THEN present them in reading order. Naively
|
|
||||||
* taking the first N by `order_global` starves the narrator on long files:
|
|
||||||
* the opening chunks of a 1000-chunk FBI dossier are cover pages, routing
|
|
||||||
* slips, classification stamps and redaction boxes — administrative front
|
|
||||||
* matter, not narrative. The substance sits deeper in the file, so a
|
|
||||||
* length-ranked pick surfaces it regardless of position, while the final
|
|
||||||
* reading-order sort keeps the story coherent.
|
|
||||||
*/
|
|
||||||
export async function fetchDocChunks(
|
|
||||||
doc_id: string,
|
|
||||||
_lang: "pt" | "en" = "pt",
|
|
||||||
limit = 24,
|
|
||||||
): Promise<SearchHit[]> {
|
|
||||||
if (!doc_id) return [];
|
|
||||||
return await query<SearchHit>(
|
|
||||||
`WITH ranked AS (
|
|
||||||
SELECT chunk_pk, doc_id, chunk_id, page, type, bbox,
|
|
||||||
content_en, content_pt, classification,
|
|
||||||
order_global, order_in_page,
|
|
||||||
length(COALESCE(content_en,'') || COALESCE(content_pt,'')) AS richness
|
|
||||||
FROM public.chunks
|
|
||||||
WHERE doc_id = $1
|
|
||||||
AND is_searchable = TRUE
|
|
||||||
AND length(COALESCE(content_en,'') || COALESCE(content_pt,'')) > 40
|
|
||||||
ORDER BY (type = 'redaction') ASC, richness DESC
|
|
||||||
LIMIT $2
|
|
||||||
)
|
|
||||||
SELECT chunk_pk, doc_id, chunk_id, page, type, bbox,
|
|
||||||
content_en, content_pt, classification,
|
|
||||||
1.0::float8 AS score, NULL::int AS bm25_rank, NULL::int AS dense_rank
|
|
||||||
FROM ranked
|
|
||||||
ORDER BY order_global ASC NULLS LAST, page ASC, order_in_page ASC`,
|
|
||||||
[doc_id, limit],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hybridSearch(opts: HybridSearchOpts): Promise<SearchHit[]> {
|
export async function hybridSearch(opts: HybridSearchOpts): Promise<SearchHit[]> {
|
||||||
const {
|
const {
|
||||||
query: q,
|
query: q,
|
||||||
|
|
|
||||||
|
|
@ -81,14 +81,7 @@ export async function generateMetadata(
|
||||||
const c = await loadCase(slug);
|
const c = await loadCase(slug);
|
||||||
if (!c) return { title: "Case file not found" };
|
if (!c) return { title: "Case file not found" };
|
||||||
|
|
||||||
// Prefer the narrator's body H1 (the magazine headline) over the generic
|
const title = locale === "pt-br" ? (c.fm.topic_pt_br ?? c.fm.topic ?? slug) : (c.fm.topic ?? slug);
|
||||||
// auto-derived frontmatter topic ("Dow Uap D44 …"). This is what search
|
|
||||||
// engines and link cards show, so it must be the human title. Two H1s when
|
|
||||||
// present: EN then PT-BR; fall back across them, then to frontmatter.
|
|
||||||
const h1s = [...c.body.matchAll(/^#\s+(.+)$/gm)].map((m) => m[1].trim());
|
|
||||||
const title = locale === "pt-br"
|
|
||||||
? (h1s[1] ?? h1s[0] ?? c.fm.topic_pt_br ?? c.fm.topic ?? slug)
|
|
||||||
: (h1s[0] ?? c.fm.topic ?? slug);
|
|
||||||
const desc = pickLead(c.body, locale).slice(0, 200);
|
const desc = pickLead(c.body, locale).slice(0, 200);
|
||||||
const canonical = `${SITE_URL}/c/${slug}`;
|
const canonical = `${SITE_URL}/c/${slug}`;
|
||||||
// OG image — use the case's editorial illustration when present. WhatsApp,
|
// OG image — use the case's editorial illustration when present. WhatsApp,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue