@polycode-projects/the-mechanical-code-talker 6.0.12 → 6.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +277 -61
- package/src/adapters/memory/shacl.mjs +2 -2
- package/src/domain/memory/trust.mjs +78 -17
- package/src/services/chat.mjs +18 -10
- package/src/services/extract-facts.mjs +36 -11
- package/src/services/news.mjs +46 -9
- package/src/surfaces/web/memory-ask-browser.bundle.js +122 -122
|
@@ -107,6 +107,60 @@ function parsePeerNodeTagRest(rest) {
|
|
|
107
107
|
const LIVE_REFERENCE_PACK = "wikipedia-live";
|
|
108
108
|
const referenceKindFor = (pack) => (pack === LIVE_REFERENCE_PACK ? "referenceLive" : "reference");
|
|
109
109
|
|
|
110
|
+
/** The part of a tag that names WHICH publication it came from, with the
|
|
111
|
+
* per-item tail cut off: `news:nyt-world@item-91` names the NYT world feed,
|
|
112
|
+
* and the item id beside it says which article, not which publisher. One feed
|
|
113
|
+
* is one asserting party however many articles it runs, so the tail stays on
|
|
114
|
+
* the tag for audit and out of the Source identity — the same split
|
|
115
|
+
* `world:<name>:turnN` and `mud:<character>:turnN` already make. */
|
|
116
|
+
const publicationKeyOf = (rest) => String(rest || "").split("@")[0];
|
|
117
|
+
|
|
118
|
+
/** The same cut for a tag the fuzzy tier labels its own Source with: the feed
|
|
119
|
+
* or the reference work, never the article. `news:nyt-world@item-91` folds to
|
|
120
|
+
* `news:nyt-world`, `research:wikidata:otter` to `research:wikidata`, and the
|
|
121
|
+
* research lane's `research:otter@2` to `research`. */
|
|
122
|
+
function publicationSourceLabel(rest) {
|
|
123
|
+
if (rest.startsWith("news:")) return publicationKeyOf(rest);
|
|
124
|
+
const { pack } = researchTagToSource(rest.slice("research:".length));
|
|
125
|
+
return `research:${pack}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `teach:chat:ingest#<tag>@<ts>` — the ingest seam driving the chat teach lane
|
|
129
|
+
* as a RECOGNIZER over a document. The party asserting is the document's own
|
|
130
|
+
* publisher, which the embedded `<tag>` names, so the record lands on that
|
|
131
|
+
* publisher's Source rather than on a chat session. Without it an ingest mints
|
|
132
|
+
* a throwaway teach Source per SENTENCE, and one publication's sentences
|
|
133
|
+
* corroborate each other for free. */
|
|
134
|
+
export const INGEST_SESSION_MARKER = "ingest#";
|
|
135
|
+
|
|
136
|
+
/** The two `research:` tag shapes, both live-fetched at query time and both
|
|
137
|
+
* scored at the referenceLive prior, below every curated pack:
|
|
138
|
+
* research:<source>:<term> one KB adapter's own lookup (researchSourceTag)
|
|
139
|
+
* research:<topic>@<depth> the research lane's fan-out, which records how
|
|
140
|
+
* far it reached rather than which adapter answered
|
|
141
|
+
* The `@` is what tells them apart — a folded term never carries one. Either
|
|
142
|
+
* way the PACK names the reference work and the article segment names the page
|
|
143
|
+
* inside it, so `sourceIdFor` can key one Source per work. */
|
|
144
|
+
function researchTagToSource(rest) {
|
|
145
|
+
const at = rest.lastIndexOf("@");
|
|
146
|
+
if (at >= 0) return { kind: "referenceLive", pack: "research", article: rest.slice(0, at).trim() || "unknown" };
|
|
147
|
+
const colon = rest.indexOf(":");
|
|
148
|
+
if (colon < 0) return { kind: "referenceLive", pack: "research", article: rest.trim() || "unknown" };
|
|
149
|
+
return { kind: "referenceLive", pack: rest.slice(0, colon) || "research", article: rest.slice(colon + 1).trim() };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The publisher a chat-shaped tag stands in for, when its session slot carries
|
|
153
|
+
* the ingest marker; null for an ordinary session, which is every tag a person
|
|
154
|
+
* actually typed. One level only — an embedded chat tag is refused rather than
|
|
155
|
+
* followed, so a hand-written tag cannot nest its way anywhere. */
|
|
156
|
+
function ingestPublisherOf(chat) {
|
|
157
|
+
if (!chat.sessionId?.startsWith(INGEST_SESSION_MARKER)) return null;
|
|
158
|
+
const embedded = chat.sessionId.slice(INGEST_SESSION_MARKER.length);
|
|
159
|
+
if (embedded.startsWith("teach:") || embedded.startsWith("ace:")) return null;
|
|
160
|
+
const publisher = provenanceTagToSource(embedded);
|
|
161
|
+
return publisher ? { ...publisher, ...(chat.createdAt ? { createdAt: chat.createdAt } : {}) } : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
110
164
|
export function provenanceTagToSource(tag) {
|
|
111
165
|
const t = String(tag || "").trim();
|
|
112
166
|
if (!t) return null;
|
|
@@ -119,17 +173,7 @@ export function provenanceTagToSource(tag) {
|
|
|
119
173
|
const pack = rest.slice(0, colon) || "unknown";
|
|
120
174
|
return { kind: referenceKindFor(pack), pack, article: rest.slice(colon + 1) };
|
|
121
175
|
}
|
|
122
|
-
|
|
123
|
-
// loads. Live-fetched at query time like the wikipedia-live pack, so it
|
|
124
|
-
// scores at the same referenceLive prior, below every curated pack. Parsed
|
|
125
|
-
// from the FULL tag (a topic may contain spaces); the depth segment records
|
|
126
|
-
// how far the fan-out reached and is not part of the Source identity.
|
|
127
|
-
if (t.startsWith("research:")) {
|
|
128
|
-
const rest = t.slice("research:".length);
|
|
129
|
-
const at = rest.lastIndexOf("@");
|
|
130
|
-
const topic = (at >= 0 ? rest.slice(0, at) : rest).trim();
|
|
131
|
-
return { kind: "referenceLive", pack: "research", article: topic || "unknown" };
|
|
132
|
-
}
|
|
176
|
+
if (t.startsWith("research:")) return researchTagToSource(t.slice("research:".length));
|
|
133
177
|
const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
|
|
134
178
|
if (head.startsWith("corpus-weak:")) return { kind: "corpusWeak", name: head.slice("corpus-weak:".length) || "unknown" };
|
|
135
179
|
if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
|
|
@@ -148,7 +192,10 @@ export function provenanceTagToSource(tag) {
|
|
|
148
192
|
// `mud:` prefix so `src:corpus:mud:<character>` can never collide with a
|
|
149
193
|
// `world:<name>` Source that happens to share the literal name.
|
|
150
194
|
if (head.startsWith("mud:")) return { kind: "corpus", name: `mud:${head.slice("mud:".length).split(":")[0] || "unknown"}` };
|
|
151
|
-
if (head.startsWith("ace:"))
|
|
195
|
+
if (head.startsWith("ace:")) {
|
|
196
|
+
const chat = parseChatTagRest(head.slice("ace:".length));
|
|
197
|
+
return ingestPublisherOf(chat) || { kind: "operator", ...chat };
|
|
198
|
+
}
|
|
152
199
|
if (head.startsWith("teach:")) {
|
|
153
200
|
const rest = head.slice("teach:".length);
|
|
154
201
|
// teach:peer:<name>#node:<id>@<ts> — a peer's own relabeled tag off the
|
|
@@ -156,7 +203,8 @@ export function provenanceTagToSource(tag) {
|
|
|
156
203
|
const peerNode = parsePeerNodeTagRest(rest);
|
|
157
204
|
if (peerNode) return peerNode;
|
|
158
205
|
// the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
|
|
159
|
-
|
|
206
|
+
const chat = parseChatTagRest(rest);
|
|
207
|
+
return ingestPublisherOf(chat) || { kind: "teach", ...chat };
|
|
160
208
|
}
|
|
161
209
|
if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
|
|
162
210
|
if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
|
|
@@ -172,10 +220,23 @@ export function provenanceTagToSource(tag) {
|
|
|
172
220
|
// the bare extracted: fallback below, which still catches every other
|
|
173
221
|
// extracted: caller unchanged. A bare news:<sourceId>@<itemId> tag (a
|
|
174
222
|
// future caller that writes it directly, with no extracted: wrapper)
|
|
175
|
-
// scores the same way.
|
|
176
|
-
|
|
177
|
-
if (head.startsWith("news:")) return { kind: "web", url: head };
|
|
178
|
-
if (head.startsWith("
|
|
223
|
+
// scores the same way. One Source per FEED: the item id rides the tag for
|
|
224
|
+
// audit and stays out of the identity.
|
|
225
|
+
if (head.startsWith("extracted:news:")) return { kind: "web", url: publicationKeyOf(head.slice("extracted:".length)) };
|
|
226
|
+
if (head.startsWith("news:")) return { kind: "web", url: publicationKeyOf(head) };
|
|
227
|
+
// The same wrapper over a KB lookup's own tag: the reference work that
|
|
228
|
+
// answered is the asserting party, not the ingest run that read it.
|
|
229
|
+
if (head.startsWith("extracted:research:")) return researchTagToSource(head.slice("extracted:research:".length));
|
|
230
|
+
// The fuzzy tier keeps a Source of its OWN — a candidate the strict
|
|
231
|
+
// recognizer skipped must never corroborate a curated fact — but one per
|
|
232
|
+
// publication, not one per article.
|
|
233
|
+
if (head.startsWith("optimistic-extract:")) {
|
|
234
|
+
const rest = head.slice("optimistic-extract:".length);
|
|
235
|
+
const publication = rest.startsWith("news:") || rest.startsWith("research:")
|
|
236
|
+
? publicationSourceLabel(rest)
|
|
237
|
+
: rest;
|
|
238
|
+
return { kind: "optimisticExtract", name: publication || "unknown" };
|
|
239
|
+
}
|
|
179
240
|
if (head.startsWith("extracted:")) return { kind: "extracted", name: head.slice("extracted:".length) || "unknown" };
|
|
180
241
|
if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
|
|
181
242
|
if (head.startsWith("chat:") || head.startsWith("session:") || head.startsWith("operator")) return { kind: "operator" };
|
package/src/services/chat.mjs
CHANGED
|
@@ -18487,14 +18487,15 @@ async function factRowSnapshot(memoryDir) {
|
|
|
18487
18487
|
try { return readStoredFactRows(await loadMemoryStore(memoryDir)); } catch { return null; }
|
|
18488
18488
|
}
|
|
18489
18489
|
|
|
18490
|
-
/** The Fact rows this turn wrote, diffed against the snapshot taken before it
|
|
18491
|
-
*
|
|
18490
|
+
/** The Fact rows this turn wrote, diffed against the snapshot taken before it,
|
|
18491
|
+
* with the after-snapshot handed back beside them. Empty when the turn had no
|
|
18492
|
+
* store to write to, or wrote nothing. */
|
|
18492
18493
|
async function factsTouchedSince(memoryDir, before) {
|
|
18493
|
-
if (!before) return [];
|
|
18494
|
+
if (!before) return { factsTouched: [], factRowsAfter: null };
|
|
18494
18495
|
const after = await factRowSnapshot(memoryDir);
|
|
18495
|
-
if (!after) return [];
|
|
18496
|
+
if (!after) return { factsTouched: [], factRowsAfter: null };
|
|
18496
18497
|
const { touchedFactRows } = await import("../domain/memory/touched-facts.mjs");
|
|
18497
|
-
return touchedFactRows(before, after);
|
|
18498
|
+
return { factsTouched: touchedFactRows(before, after), factRowsAfter: after };
|
|
18498
18499
|
}
|
|
18499
18500
|
|
|
18500
18501
|
/** A whole-line miss whose only problem is a closed filler clause in front of a
|
|
@@ -18519,12 +18520,19 @@ async function answerWithoutFillerPrefix(input, options, missed) {
|
|
|
18519
18520
|
}
|
|
18520
18521
|
|
|
18521
18522
|
/** Run one turn and report which Fact rows it wrote, as `factsTouched` beside
|
|
18522
|
-
* the answer/record/logLines every caller already reads
|
|
18523
|
-
*
|
|
18524
|
-
*
|
|
18523
|
+
* the answer/record/logLines every caller already reads, with the after-fold
|
|
18524
|
+
* the diff was taken against as `factRowsAfter`. The dispatch itself is
|
|
18525
|
+
* dispatchTurn, below; this wrapper exists so the field lands on EVERY return
|
|
18526
|
+
* path (dispatched, conversational, multi-sentence) from one place.
|
|
18527
|
+
*
|
|
18528
|
+
* `options.factRowsBefore` is the caller's own already-folded view of the
|
|
18529
|
+
* store, standing in for the before-snapshot. A caller running many turns over
|
|
18530
|
+
* one document folds once and threads it; folding a seed-sized graph again per
|
|
18531
|
+
* turn, to read back the rows the caller just handed over, is the most
|
|
18532
|
+
* expensive thing an ingest does. */
|
|
18525
18533
|
export async function runTurn(input, options = {}) {
|
|
18526
18534
|
const memoryDir = options?.memoryDir ?? null;
|
|
18527
|
-
const before = await factRowSnapshot(memoryDir);
|
|
18535
|
+
const before = options?.factRowsBefore || await factRowSnapshot(memoryDir);
|
|
18528
18536
|
let result;
|
|
18529
18537
|
try {
|
|
18530
18538
|
result = await dispatchTurn(input, options);
|
|
@@ -18541,7 +18549,7 @@ export async function runTurn(input, options = {}) {
|
|
|
18541
18549
|
});
|
|
18542
18550
|
}
|
|
18543
18551
|
if (!result || typeof result !== "object") return result;
|
|
18544
|
-
return { ...result,
|
|
18552
|
+
return { ...result, ...(await factsTouchedSince(memoryDir, before)) };
|
|
18545
18553
|
}
|
|
18546
18554
|
|
|
18547
18555
|
async function dispatchTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, researchSource = null, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, newsState = null, newsConfig = null, newsProviders = null, discourse = null, _noSplit = false, actingSubject = "player", codeDomainActive = null, laneVocab = null, domainPacks = null, retrieval = null } = {}) {
|
|
@@ -63,9 +63,10 @@ import { basename, join, resolve } from "node:path";
|
|
|
63
63
|
import { runTurn, uuidv7, stripLeadingDiscourseAdverb } from "./chat.mjs";
|
|
64
64
|
import { beginsWithVowelSound, grammarRules } from "./finish.mjs";
|
|
65
65
|
import { splitSentencesPreservingPaths, stripCitationResidue } from "./sentences.mjs";
|
|
66
|
-
import { loadMemory, readFactRows,
|
|
66
|
+
import { loadMemory, readFactRows, appendFacts, removeFacts } from "../adapters/memory/core.mjs";
|
|
67
67
|
import { loadConfig } from "../adapters/config.mjs";
|
|
68
68
|
import { touchedFactRows } from "../domain/memory/touched-facts.mjs";
|
|
69
|
+
import { INGEST_SESSION_MARKER } from "../domain/memory/trust.mjs";
|
|
69
70
|
import { normFactTerm } from "../domain/hash.mjs";
|
|
70
71
|
import { splitIdentifierWords } from "../domain/prose.mjs";
|
|
71
72
|
import { winkInstance } from "../adapters/wink-model.mjs";
|
|
@@ -104,18 +105,22 @@ export function parseArgs(argv) {
|
|
|
104
105
|
* on a browser-sized graph — so the caller threads one fold from sentence to
|
|
105
106
|
* sentence instead of paying a fresh one per candidate.
|
|
106
107
|
*/
|
|
107
|
-
async function runSentence(sentence, { config, memoryDir, env, beforeRows }) {
|
|
108
|
+
async function runSentence(sentence, { config, memoryDir, env, beforeRows, sessionId = "" }) {
|
|
108
109
|
const before = beforeRows || readFactRows(await loadMemory(memoryDir));
|
|
109
110
|
if (ingestYield) await ingestYield();
|
|
110
|
-
|
|
111
|
+
// The turn takes the caller's fold as its own before-view and hands back the
|
|
112
|
+
// after-fold it already had to take, so one sentence costs one fold rather
|
|
113
|
+
// than three of the same graph.
|
|
114
|
+
const { record, answer, factsTouched, factRowsAfter } = await runTurn(sentence, {
|
|
115
|
+
config, memoryDir, sessionId: sessionId || uuidv7(), env, factRowsBefore: before,
|
|
116
|
+
});
|
|
117
|
+
const after = factRowsAfter || before;
|
|
111
118
|
// Only an assert turn can have written a Fact, so only an assert turn earns
|
|
112
|
-
//
|
|
113
|
-
// back untouched.
|
|
119
|
+
// a fresh view; every other turn hands the caller's own straight back.
|
|
114
120
|
if (record?.via !== "assert") return { recognized: false, rows: [], afterRows: before, decline: String(answer || "") };
|
|
115
121
|
if (ingestYield) await ingestYield();
|
|
116
|
-
const after = readFactRows(await loadMemory(memoryDir));
|
|
117
122
|
if (record?.miss) return { recognized: false, rows: [], afterRows: after, decline: String(answer || "") };
|
|
118
|
-
return { recognized: true, rows: touchedFactRows(before, after), afterRows: after };
|
|
123
|
+
return { recognized: true, rows: factsTouched || touchedFactRows(before, after), afterRows: after };
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
/** The recognizer's own words for why it turned a sentence down, when it named
|
|
@@ -800,6 +805,13 @@ function canonicalLines(facts, storeRows) {
|
|
|
800
805
|
* are the only output.
|
|
801
806
|
* sourceTag the label the audit provenance carries (extracted:<tag> /
|
|
802
807
|
* optimistic-extract:<tag>). Default "text".
|
|
808
|
+
* attributeToSource
|
|
809
|
+
* file the recognizer's own assertion under `sourceTag`'s
|
|
810
|
+
* publication instead of a fresh chat session per sentence.
|
|
811
|
+
* Off by default: an operator running `tmct extract` over their
|
|
812
|
+
* own notes IS the asserting party, so that lane keeps minting
|
|
813
|
+
* a session. A feed or a reference work is not, and one
|
|
814
|
+
* publication's sentences must never corroborate each other.
|
|
803
815
|
* optimistic also run the fuzzy tier over strict-skipped sentences.
|
|
804
816
|
* canonical include a `canonical` array: one enriched triple line per
|
|
805
817
|
* ingested fact.
|
|
@@ -830,7 +842,12 @@ function canonicalLines(facts, storeRows) {
|
|
|
830
842
|
export async function ingestText(text, {
|
|
831
843
|
memoryDir = null, sourceTag = "text", optimistic = false,
|
|
832
844
|
canonical = false, config = null, lexicon = null, observedAt = "", findings = false,
|
|
845
|
+
attributeToSource = false,
|
|
833
846
|
} = {}) {
|
|
847
|
+
// The session id every sentence's recognizer turn runs under. Stable and
|
|
848
|
+
// derived from the publication when the caller attributes to it, so the whole
|
|
849
|
+
// run lands on one Source; a fresh uuid per sentence otherwise (runSentence).
|
|
850
|
+
const recognizerSessionId = attributeToSource ? `${INGEST_SESSION_MARKER}${sourceTag.split("@")[0]}` : "";
|
|
834
851
|
// Paragraphs first (blank-line separated), so the pronoun carry never bridges
|
|
835
852
|
// a topic break: a fresh paragraph clears the last-subject it would resolve
|
|
836
853
|
// "they"/"it" against. Each paragraph then splits into sentences the shared
|
|
@@ -900,7 +917,7 @@ export async function ingestText(text, {
|
|
|
900
917
|
if (ingestYield) await ingestYield();
|
|
901
918
|
const knownIds = new Set(currentRows.map((r) => r.id));
|
|
902
919
|
const { recognized, rows, afterRows, decline } = await runSentence(form, {
|
|
903
|
-
config: cfg, memoryDir: dir, env: runEnv, beforeRows: currentRows,
|
|
920
|
+
config: cfg, memoryDir: dir, env: runEnv, beforeRows: currentRows, sessionId: recognizerSessionId,
|
|
904
921
|
});
|
|
905
922
|
currentRows = afterRows;
|
|
906
923
|
if (!recognized) { lastDecline = decline || lastDecline; return null; }
|
|
@@ -971,9 +988,14 @@ export async function ingestText(text, {
|
|
|
971
988
|
const subjects = new Set(rows.map((r) => r.subject));
|
|
972
989
|
if (subjects.size === 1) carrySubject = [...subjects][0];
|
|
973
990
|
const tag = `extracted:${sourceTag}`;
|
|
991
|
+
// One write for the whole sentence, not one per row: a write reads
|
|
992
|
+
// and re-derives the whole graph, so N of them cost N times what one
|
|
993
|
+
// carrying the same N rows does. The batch stays inside the sentence
|
|
994
|
+
// — a later sentence still reads everything the earlier ones wrote.
|
|
995
|
+
const writes = [];
|
|
974
996
|
for (const row of rows) {
|
|
975
997
|
const extraction = findingsForRow(row, readingFindings, identifierTerms);
|
|
976
|
-
|
|
998
|
+
writes.push({
|
|
977
999
|
subject: row.subject, predicate: row.predicate, object: row.object,
|
|
978
1000
|
provenance: tag, quantifier: row.quantifier || "", observedAt,
|
|
979
1001
|
...(extraction.length ? { extraction } : {}),
|
|
@@ -985,6 +1007,7 @@ export async function ingestText(text, {
|
|
|
985
1007
|
});
|
|
986
1008
|
taggedIds.add(row.id);
|
|
987
1009
|
}
|
|
1010
|
+
await appendFacts(dir, writes);
|
|
988
1011
|
continue;
|
|
989
1012
|
}
|
|
990
1013
|
const ungrounded = ungroundedTermsIn(lastDecline);
|
|
@@ -1009,15 +1032,17 @@ export async function ingestText(text, {
|
|
|
1009
1032
|
if (!keptCandidates.length) continue;
|
|
1010
1033
|
optimisticSentences += 1;
|
|
1011
1034
|
const tag = `optimistic-extract:${sourceTag}`;
|
|
1035
|
+
const candidateWrites = [];
|
|
1012
1036
|
for (const t of keptCandidates) {
|
|
1013
1037
|
const extraction = findingsForRow(t, mintFindings.has(t) ? [mintFindings.get(t)] : [], identifierTerms);
|
|
1014
|
-
|
|
1038
|
+
candidateWrites.push({
|
|
1015
1039
|
subject: t.subject, predicate: t.predicate, object: t.object, provenance: tag, observedAt,
|
|
1016
1040
|
...(extraction.length ? { extraction } : {}),
|
|
1017
1041
|
});
|
|
1018
1042
|
optimisticFacts.push({ ...t, provenance: tag, sentence, ...(extraction.length ? { extraction } : {}) });
|
|
1019
|
-
taggedIds.add(written.id);
|
|
1020
1043
|
}
|
|
1044
|
+
const { ids } = await appendFacts(dir, candidateWrites);
|
|
1045
|
+
for (const id of ids) taggedIds.add(id);
|
|
1021
1046
|
}
|
|
1022
1047
|
}
|
|
1023
1048
|
|
package/src/services/news.mjs
CHANGED
|
@@ -338,6 +338,7 @@ async function ingestSnapshotFacts(ctx, snapshot) {
|
|
|
338
338
|
const sourceTag = `news:${snapshot.sourceId}@${snapshot.id}`;
|
|
339
339
|
const result = await ingestText(text, {
|
|
340
340
|
memoryDir, sourceTag, optimistic: true, lexicon: lex, observedAt: nowVal, findings: true,
|
|
341
|
+
attributeToSource: true,
|
|
341
342
|
});
|
|
342
343
|
invalidateCache(cache);
|
|
343
344
|
|
|
@@ -494,6 +495,27 @@ function mergeSnapshotsById(existing, incoming, cap) {
|
|
|
494
495
|
return { items, added };
|
|
495
496
|
}
|
|
496
497
|
|
|
498
|
+
const fetchedAtMs = (snapshot) => {
|
|
499
|
+
const ms = toMs(snapshot?.fetchedAt);
|
|
500
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
/** True once a snapshot has been through a grounding round. `mergeSnapshotsById`
|
|
504
|
+
* files a snapshot the moment it arrives, so "known" and "grounded" are two
|
|
505
|
+
* different states and only this one means the facts landed. */
|
|
506
|
+
const isGroundedSnapshot = (snapshot) => (snapshot?.processedRounds || 0) > 0;
|
|
507
|
+
|
|
508
|
+
/** One source's fetched-but-not-yet-grounded snapshots, oldest first. A cycle
|
|
509
|
+
* that ran out of time leaves its backlog here, so the next cycle works
|
|
510
|
+
* through that before anything newer and the same article is never ingested
|
|
511
|
+
* twice. Ordered off the snapshots' own fields, so two cycles reading the same
|
|
512
|
+
* state take the same work in the same order. */
|
|
513
|
+
function pendingSnapshotsFor(items, sourceId) {
|
|
514
|
+
return (items || [])
|
|
515
|
+
.filter((snap) => snap?.sourceId === sourceId && !isGroundedSnapshot(snap))
|
|
516
|
+
.sort((a, b) => (fetchedAtMs(a) - fetchedAtMs(b)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
517
|
+
}
|
|
518
|
+
|
|
497
519
|
function emptyCycleAccumulator(at) {
|
|
498
520
|
return { at, sentences: 0, recognized: 0, optimisticCount: 0, factsAdded: 0, termsResolved: 0, derived: 0 };
|
|
499
521
|
}
|
|
@@ -548,21 +570,28 @@ export async function pollNewsSources(ctx) {
|
|
|
548
570
|
perSource.push({ sourceId, status: "failed" });
|
|
549
571
|
continue;
|
|
550
572
|
}
|
|
573
|
+
let added = [];
|
|
551
574
|
if (result.notModified) {
|
|
552
575
|
recordSuccess(health, nowVal, "not-modified");
|
|
553
|
-
|
|
554
|
-
|
|
576
|
+
} else {
|
|
577
|
+
recordSuccess(health, nowVal, "ok");
|
|
578
|
+
const merged = mergeSnapshotsById(state.items, result.items, config.itemCap);
|
|
579
|
+
state.items = merged.items;
|
|
580
|
+
added = merged.added;
|
|
581
|
+
newItemsTotal += added.length;
|
|
555
582
|
}
|
|
556
|
-
recordSuccess(health, nowVal, "ok");
|
|
557
|
-
const { items: mergedItems, added } = mergeSnapshotsById(state.items, result.items, config.itemCap);
|
|
558
|
-
state.items = mergedItems;
|
|
559
|
-
newItemsTotal += added.length;
|
|
560
583
|
|
|
584
|
+
// Everything this source has fetched and not yet grounded, not just what
|
|
585
|
+
// arrived on this fetch: a source that answers 304 still has a backlog to
|
|
586
|
+
// finish, and an aborted cycle's leftovers would otherwise sit in the state
|
|
587
|
+
// marked known and never be read again.
|
|
561
588
|
const before = emptyCycleAccumulator(nowVal);
|
|
562
589
|
const after = emptyCycleAccumulator(nowVal);
|
|
563
|
-
|
|
590
|
+
let grounded = 0;
|
|
591
|
+
for (const snapshot of pendingSnapshotsFor(state.items, sourceId)) {
|
|
564
592
|
if (shouldAbort()) { aborted = true; break; }
|
|
565
593
|
const r = await ingestNewsSnapshot(ctx, snapshot);
|
|
594
|
+
grounded += 1;
|
|
566
595
|
after.sentences += r.sentences;
|
|
567
596
|
after.recognized += r.recognized;
|
|
568
597
|
after.optimisticCount += r.optimisticCount;
|
|
@@ -571,10 +600,17 @@ export async function pollNewsSources(ctx) {
|
|
|
571
600
|
factsTotal += r.facts;
|
|
572
601
|
derivedTotal += r.derived;
|
|
573
602
|
}
|
|
574
|
-
if (
|
|
603
|
+
if (grounded) {
|
|
575
604
|
state.metrics = [...(state.metrics || []), cycleMetrics(before, after, { source: sourceId })];
|
|
576
605
|
}
|
|
577
|
-
|
|
606
|
+
const pendingLeft = pendingSnapshotsFor(state.items, sourceId).length;
|
|
607
|
+
perSource.push({
|
|
608
|
+
sourceId,
|
|
609
|
+
status: result.notModified ? "not-modified" : "ok",
|
|
610
|
+
newItems: added.length,
|
|
611
|
+
grounded,
|
|
612
|
+
pending: pendingLeft,
|
|
613
|
+
});
|
|
578
614
|
if (aborted) break;
|
|
579
615
|
}
|
|
580
616
|
|
|
@@ -627,6 +663,7 @@ async function ingestResearchArticle(ctx, term, provider, article) {
|
|
|
627
663
|
ingested = await ingestText(prose, {
|
|
628
664
|
memoryDir, sourceTag: provenance, optimistic: true,
|
|
629
665
|
lexicon: lexicon || loadLexicon(), observedAt: resolveNow(now), findings: true,
|
|
666
|
+
attributeToSource: true,
|
|
630
667
|
});
|
|
631
668
|
invalidateCache(ctx.cache);
|
|
632
669
|
}
|