@veewo/claw-core 0.1.85 → 0.1.87
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/dist/src/context.js +5 -1
- package/dist/src/context.js.map +1 -1
- package/dist/src/embedding-daemon-protocol.d.ts +4 -0
- package/dist/src/embedding-daemon-protocol.js +9 -3
- package/dist/src/embedding-daemon-protocol.js.map +1 -1
- package/dist/src/embedding-defaults.d.ts +1 -1
- package/dist/src/embedding-defaults.js +4 -2
- package/dist/src/embedding-defaults.js.map +1 -1
- package/dist/src/embedding-token-chunker.d.ts +10 -0
- package/dist/src/embedding-token-chunker.js +123 -0
- package/dist/src/embedding-token-chunker.js.map +1 -0
- package/dist/src/embedding-worker.js +32 -5
- package/dist/src/embedding-worker.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/init.js +2 -1
- package/dist/src/init.js.map +1 -1
- package/dist/src/knowledge-document.d.ts +27 -0
- package/dist/src/knowledge-document.js +189 -0
- package/dist/src/knowledge-document.js.map +1 -0
- package/dist/src/knowledge-governance.d.ts +22 -0
- package/dist/src/knowledge-governance.js +75 -0
- package/dist/src/knowledge-governance.js.map +1 -0
- package/dist/src/knowledge-sidecar.d.ts +3 -0
- package/dist/src/knowledge-sidecar.js +1 -0
- package/dist/src/knowledge-sidecar.js.map +1 -1
- package/dist/src/memory-query.d.ts +2 -0
- package/dist/src/memory-query.js +25 -4
- package/dist/src/memory-query.js.map +1 -1
- package/dist/src/memory.js +442 -65
- package/dist/src/memory.js.map +1 -1
- package/dist/src/plan-templates.d.ts +1 -0
- package/dist/src/plan-templates.js +61 -10
- package/dist/src/plan-templates.js.map +1 -1
- package/dist/src/project-check.js +6 -1
- package/dist/src/project-check.js.map +1 -1
- package/dist/src/project-defaults.d.ts +1 -0
- package/dist/src/project-defaults.js +1 -0
- package/dist/src/project-defaults.js.map +1 -1
- package/dist/src/templates/plans/default.d.ts +1 -0
- package/dist/src/templates/plans/default.js +3 -2
- package/dist/src/templates/plans/default.js.map +1 -1
- package/dist/src/types.d.ts +6 -0
- package/dist/src/workflow-guidance.config.json +4 -4
- package/package.json +1 -1
package/dist/src/memory.js
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveDefaultLocalEmbeddingDimensions } from "./embedding-defaults.js"
|
|
|
10
10
|
import { requestPersistentEmbedding } from "./embedding-daemon-protocol.js";
|
|
11
11
|
import { ClawError } from "./errors.js";
|
|
12
12
|
import { readJsonFile, readTextFile } from "./io.js";
|
|
13
|
+
import { analyzeKnowledgeDocument } from "./knowledge-document.js";
|
|
13
14
|
import { buildProjectKeywordSearchPlan, buildProjectQueryIntent } from "./memory-query.js";
|
|
14
15
|
const DEFAULT_PROJECT_REFRESH_FILE_LIMIT = 100;
|
|
15
16
|
const PROJECT_SEARCH_CANDIDATE_MULTIPLIER = 8;
|
|
@@ -21,6 +22,7 @@ const DEFAULT_EMBEDDING_MAX_CHARS = DEFAULT_EMBEDDING_MAX_TOKENS * DEFAULT_EMBED
|
|
|
21
22
|
const DEFAULT_MEMORY_SQLITE_BUSY_TIMEOUT_MS = 5000;
|
|
22
23
|
const PROJECT_QUERY_EMBEDDING_CACHE_LIMIT = 128;
|
|
23
24
|
const PROJECT_QUERY_EMBEDDING_CACHE_VERSION = "v1";
|
|
25
|
+
const PROJECT_EMBEDDING_CHUNKING_VERSION = "generic-knowledge-markers-v3";
|
|
24
26
|
export function buildMemoryIndex(input) {
|
|
25
27
|
const { scope, project, task } = resolveMemoryScope(input);
|
|
26
28
|
if (!isProjectMemoryEnabled(project)) {
|
|
@@ -51,9 +53,11 @@ export function buildMemoryIndex(input) {
|
|
|
51
53
|
upsertMetadata(db, "indexed_at", new Date().toISOString());
|
|
52
54
|
if (embedding) {
|
|
53
55
|
upsertMetadata(db, "embedding_config", JSON.stringify(embedding));
|
|
56
|
+
upsertMetadata(db, "embedding_chunking_version", PROJECT_EMBEDDING_CHUNKING_VERSION);
|
|
54
57
|
}
|
|
55
58
|
else {
|
|
56
59
|
deleteMetadata(db, "embedding_config");
|
|
60
|
+
deleteMetadata(db, "embedding_chunking_version");
|
|
57
61
|
}
|
|
58
62
|
if (syncResult.vectorIndex) {
|
|
59
63
|
upsertMetadata(db, "vector_index", JSON.stringify(syncResult.vectorIndex));
|
|
@@ -87,8 +91,11 @@ function rebuildTaskMemoryIndex(db, sources) {
|
|
|
87
91
|
function syncProjectMemoryIndex(db, sources, embedding, maxFiles) {
|
|
88
92
|
const currentEmbeddingConfig = embedding ? JSON.stringify(embedding) : null;
|
|
89
93
|
const storedEmbeddingConfig = getMetadata(db, "embedding_config");
|
|
94
|
+
const storedChunkingVersion = getMetadata(db, "embedding_chunking_version");
|
|
90
95
|
const shouldIndexVectors = canBuildProjectVectors(embedding);
|
|
91
|
-
const requiresVectorReset = storedEmbeddingConfig !== currentEmbeddingConfig
|
|
96
|
+
const requiresVectorReset = storedEmbeddingConfig !== currentEmbeddingConfig
|
|
97
|
+
|| storedChunkingVersion !== PROJECT_EMBEDDING_CHUNKING_VERSION
|
|
98
|
+
|| !shouldIndexVectors;
|
|
92
99
|
const nextSources = sources.map((source) => ({
|
|
93
100
|
...source,
|
|
94
101
|
contentHash: hashMemoryContent(source.content),
|
|
@@ -446,6 +453,11 @@ function prepareSchema(db) {
|
|
|
446
453
|
" source_path TEXT NOT NULL,",
|
|
447
454
|
" kind TEXT NOT NULL,",
|
|
448
455
|
" chunk_text TEXT NOT NULL,",
|
|
456
|
+
" heading_path TEXT NOT NULL DEFAULT '',",
|
|
457
|
+
" document_kind TEXT NOT NULL DEFAULT 'other',",
|
|
458
|
+
" document_state TEXT,",
|
|
459
|
+
" chunk_state TEXT,",
|
|
460
|
+
" dated TEXT,",
|
|
449
461
|
" embedding_json TEXT NOT NULL,",
|
|
450
462
|
" PRIMARY KEY (doc_id, chunk_index)",
|
|
451
463
|
");",
|
|
@@ -462,6 +474,19 @@ function prepareSchema(db) {
|
|
|
462
474
|
if (!docsColumns.some((column) => column.name === "content_hash")) {
|
|
463
475
|
db.exec("ALTER TABLE docs ADD COLUMN content_hash TEXT;");
|
|
464
476
|
}
|
|
477
|
+
const embeddingColumns = db.prepare("PRAGMA table_info(doc_embeddings)").all();
|
|
478
|
+
const embeddingMigrations = [
|
|
479
|
+
["heading_path", "TEXT NOT NULL DEFAULT ''"],
|
|
480
|
+
["document_kind", "TEXT NOT NULL DEFAULT 'other'"],
|
|
481
|
+
["document_state", "TEXT"],
|
|
482
|
+
["chunk_state", "TEXT"],
|
|
483
|
+
["dated", "TEXT"],
|
|
484
|
+
];
|
|
485
|
+
for (const [name, declaration] of embeddingMigrations) {
|
|
486
|
+
if (!embeddingColumns.some((column) => column.name === name)) {
|
|
487
|
+
db.exec(`ALTER TABLE doc_embeddings ADD COLUMN ${name} ${declaration};`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
465
490
|
}
|
|
466
491
|
function resolveProjectMemoryEmbeddingConfig(project) {
|
|
467
492
|
if (!isProjectMemoryEnabled(project)) {
|
|
@@ -569,32 +594,56 @@ function generateDocEmbeddings(docs, embedding) {
|
|
|
569
594
|
if (!canBuildProjectVectors(embedding)) {
|
|
570
595
|
return [];
|
|
571
596
|
}
|
|
572
|
-
const chunks = docs.flatMap((doc) => chunkMarkdownContent(doc.content).map((
|
|
597
|
+
const chunks = docs.flatMap((doc) => chunkMarkdownContent(doc.content, doc.sourcePath, doc.kind).map((chunk, chunkIndex) => ({
|
|
573
598
|
docId: doc.docId,
|
|
574
599
|
chunkIndex,
|
|
575
600
|
sourcePath: doc.sourcePath,
|
|
576
601
|
kind: doc.kind,
|
|
577
|
-
|
|
602
|
+
...chunk,
|
|
578
603
|
})));
|
|
579
604
|
if (chunks.length === 0) {
|
|
580
605
|
return [];
|
|
581
606
|
}
|
|
582
607
|
const output = runEmbeddingWorker({
|
|
583
608
|
embedding,
|
|
584
|
-
texts: chunks.map((chunk) =>
|
|
609
|
+
texts: chunks.map((chunk) => embedding.provider === "local"
|
|
610
|
+
? chunk.bodyText
|
|
611
|
+
: joinChunkContext(chunk.contextPrefix, chunk.bodyText)),
|
|
612
|
+
...(embedding.provider === "local"
|
|
613
|
+
? { textPrefixes: chunks.map((chunk) => chunk.contextPrefix) }
|
|
614
|
+
: {}),
|
|
615
|
+
splitIntoTokenWindows: embedding.provider === "local",
|
|
585
616
|
});
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
617
|
+
const segments = output.segments ?? chunks.map((chunk, sourceTextIndex) => ({
|
|
618
|
+
sourceTextIndex,
|
|
619
|
+
text: joinChunkContext(chunk.contextPrefix, chunk.bodyText),
|
|
589
620
|
}));
|
|
621
|
+
if (output.vectors.length !== segments.length) {
|
|
622
|
+
throw new Error(`Embedding worker returned ${output.vectors.length} vectors for ${segments.length} text segments.`);
|
|
623
|
+
}
|
|
624
|
+
const nextChunkIndexByDoc = new Map();
|
|
625
|
+
return segments.map((segment, index) => {
|
|
626
|
+
const source = chunks[segment.sourceTextIndex];
|
|
627
|
+
if (!source) {
|
|
628
|
+
throw new Error(`Embedding worker returned an invalid source text index: ${segment.sourceTextIndex}`);
|
|
629
|
+
}
|
|
630
|
+
const chunkIndex = nextChunkIndexByDoc.get(source.docId) ?? 0;
|
|
631
|
+
nextChunkIndexByDoc.set(source.docId, chunkIndex + 1);
|
|
632
|
+
return {
|
|
633
|
+
...source,
|
|
634
|
+
chunkIndex,
|
|
635
|
+
chunkText: segment.text,
|
|
636
|
+
vector: output.vectors[index] ?? [],
|
|
637
|
+
};
|
|
638
|
+
});
|
|
590
639
|
}
|
|
591
640
|
function insertDocEmbeddings(db, embeddings) {
|
|
592
641
|
const insertEmbedding = db.prepare([
|
|
593
|
-
"INSERT OR REPLACE INTO doc_embeddings (doc_id, chunk_index, source_path, kind, chunk_text, embedding_json)",
|
|
594
|
-
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
642
|
+
"INSERT OR REPLACE INTO doc_embeddings (doc_id, chunk_index, source_path, kind, chunk_text, heading_path, document_kind, document_state, chunk_state, dated, embedding_json)",
|
|
643
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
595
644
|
].join(" "));
|
|
596
645
|
embeddings.forEach((embedding) => {
|
|
597
|
-
insertEmbedding.run(embedding.docId, embedding.chunkIndex, embedding.sourcePath, embedding.kind, embedding.chunkText, JSON.stringify(embedding.vector));
|
|
646
|
+
insertEmbedding.run(embedding.docId, embedding.chunkIndex, embedding.sourcePath, embedding.kind, embedding.chunkText, embedding.headingPath, embedding.documentKind, embedding.documentState, embedding.state, embedding.dated, JSON.stringify(embedding.vector));
|
|
598
647
|
});
|
|
599
648
|
}
|
|
600
649
|
function listDocsMissingEmbeddings(db) {
|
|
@@ -647,44 +696,158 @@ function canBuildProjectVectors(embedding) {
|
|
|
647
696
|
function hashMemoryContent(content) {
|
|
648
697
|
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
649
698
|
}
|
|
650
|
-
function chunkMarkdownContent(content) {
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
let
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
699
|
+
function chunkMarkdownContent(content, sourcePath, sourceKind) {
|
|
700
|
+
const isCanonicalKnowledge = sourceKind === "truth_doc";
|
|
701
|
+
const analysis = isCanonicalKnowledge ? analyzeKnowledgeDocument(content, sourcePath) : null;
|
|
702
|
+
const documentKind = analysis?.kind ?? "other";
|
|
703
|
+
const documentState = analysis?.state ?? null;
|
|
704
|
+
const headingStack = [];
|
|
705
|
+
let inFence = false;
|
|
706
|
+
let pendingSectionState = null;
|
|
707
|
+
let pendingDate = null;
|
|
708
|
+
const paragraphs = content
|
|
709
|
+
.split(/\r?\n\s*\r?\n/gu)
|
|
710
|
+
.map((paragraph) => paragraph.trim())
|
|
711
|
+
.filter(Boolean);
|
|
712
|
+
const pieces = [];
|
|
713
|
+
for (const paragraph of paragraphs) {
|
|
714
|
+
const bodyLines = [];
|
|
715
|
+
for (const line of paragraph.split(/\r?\n/gu)) {
|
|
716
|
+
if (/^\s*(```|~~~)/u.test(line)) {
|
|
717
|
+
inFence = !inFence;
|
|
718
|
+
bodyLines.push(line);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
if (inFence) {
|
|
722
|
+
bodyLines.push(line);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const sectionState = parseKnowledgeStateComment(line);
|
|
726
|
+
if (sectionState) {
|
|
727
|
+
pendingSectionState = sectionState;
|
|
728
|
+
pendingDate = null;
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const dated = parseKnowledgeDatedComment(line);
|
|
732
|
+
if (dated) {
|
|
733
|
+
pendingDate = dated;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (isKnowledgeDocumentStateComment(line)) {
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
bodyLines.push(line);
|
|
740
|
+
const heading = /^(#{1,6})\s+(.+?)\s*$/u.exec(line.trim());
|
|
741
|
+
if (!heading) {
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
const level = heading[1].length;
|
|
745
|
+
const replaceFrom = headingStack.findIndex((entry) => entry.level >= level);
|
|
746
|
+
if (replaceFrom >= 0) {
|
|
747
|
+
headingStack.splice(replaceFrom);
|
|
748
|
+
}
|
|
749
|
+
const headingText = heading[2].trim();
|
|
750
|
+
headingStack.push({
|
|
751
|
+
level,
|
|
752
|
+
text: headingText,
|
|
753
|
+
state: pendingSectionState,
|
|
754
|
+
dated: pendingDate,
|
|
755
|
+
});
|
|
756
|
+
pendingSectionState = null;
|
|
757
|
+
pendingDate = null;
|
|
662
758
|
}
|
|
663
|
-
|
|
664
|
-
|
|
759
|
+
const bodyParagraph = bodyLines.join("\n").trim();
|
|
760
|
+
if (!bodyParagraph) {
|
|
665
761
|
continue;
|
|
666
762
|
}
|
|
667
|
-
|
|
668
|
-
|
|
763
|
+
const headingPath = headingStack.map((entry) => entry.text).join(" > ");
|
|
764
|
+
const state = [...headingStack].reverse().find((entry) => entry.state)?.state
|
|
765
|
+
?? documentState;
|
|
766
|
+
const dated = [...headingStack].reverse().find((entry) => entry.dated)?.dated ?? null;
|
|
767
|
+
const contextPrefix = buildChunkContextPrefix({
|
|
768
|
+
headingPath,
|
|
769
|
+
documentKind,
|
|
770
|
+
documentState,
|
|
771
|
+
state,
|
|
772
|
+
dated,
|
|
773
|
+
});
|
|
774
|
+
const contextSeparatorLength = contextPrefix ? 2 : 0;
|
|
775
|
+
const maxBodyChars = Math.max(256, DEFAULT_EMBEDDING_MAX_CHARS - contextPrefix.length - contextSeparatorLength);
|
|
776
|
+
const targetBodyChars = Math.max(128, DEFAULT_EMBEDDING_TARGET_CHARS - contextPrefix.length - contextSeparatorLength);
|
|
777
|
+
for (const bodyText of splitOversizedMarkdownChunk(bodyParagraph, maxBodyChars, targetBodyChars)) {
|
|
778
|
+
pieces.push({
|
|
779
|
+
bodyText,
|
|
780
|
+
contextPrefix,
|
|
781
|
+
headingPath,
|
|
782
|
+
documentKind,
|
|
783
|
+
documentState,
|
|
784
|
+
state,
|
|
785
|
+
dated,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
669
788
|
}
|
|
670
|
-
|
|
671
|
-
|
|
789
|
+
const merged = [];
|
|
790
|
+
for (const piece of pieces) {
|
|
791
|
+
const previous = merged[merged.length - 1];
|
|
792
|
+
if (previous
|
|
793
|
+
&& sameChunkContext(previous, piece)
|
|
794
|
+
&& joinChunkContext(previous.contextPrefix, `${previous.bodyText}\n\n${piece.bodyText}`).length
|
|
795
|
+
<= DEFAULT_EMBEDDING_TARGET_CHARS) {
|
|
796
|
+
previous.bodyText = `${previous.bodyText}\n\n${piece.bodyText}`;
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
merged.push({ ...piece });
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return merged;
|
|
803
|
+
}
|
|
804
|
+
function parseKnowledgeStateComment(line) {
|
|
805
|
+
const match = /^\s*<!--\s*state:\s*(current|accepted|history|historical|superseded)\s*-->\s*$/iu.exec(line);
|
|
806
|
+
const value = match?.[1].toLowerCase();
|
|
807
|
+
if (value === "history" || value === "historical") {
|
|
808
|
+
return "historical";
|
|
672
809
|
}
|
|
673
|
-
return
|
|
810
|
+
return value === "current" || value === "accepted" || value === "superseded" ? value : null;
|
|
674
811
|
}
|
|
675
|
-
function
|
|
676
|
-
|
|
812
|
+
function parseKnowledgeDatedComment(line) {
|
|
813
|
+
return /^\s*<!--\s*dated:\s*(\d{4}-\d{2}-\d{2})\s*-->\s*$/iu.exec(line)?.[1] ?? null;
|
|
814
|
+
}
|
|
815
|
+
function isKnowledgeDocumentStateComment(line) {
|
|
816
|
+
return /^\s*<!--\s*document-state:\s*(?:current|accepted|history|historical|superseded)\s*-->\s*$/iu.test(line);
|
|
817
|
+
}
|
|
818
|
+
function buildChunkContextPrefix(input) {
|
|
819
|
+
const parts = [];
|
|
820
|
+
if (input.documentKind !== "other") {
|
|
821
|
+
parts.push(`[knowledge:doc=${input.documentKind} doc_state=${input.documentState ?? "unknown"} state=${input.state ?? "unknown"}${input.dated ? ` dated=${input.dated}` : ""}]`);
|
|
822
|
+
}
|
|
823
|
+
if (input.headingPath) {
|
|
824
|
+
parts.push(`Heading: ${input.headingPath}`);
|
|
825
|
+
}
|
|
826
|
+
return parts.join("\n");
|
|
827
|
+
}
|
|
828
|
+
function joinChunkContext(prefix, body) {
|
|
829
|
+
return prefix ? `${prefix}\n\n${body}` : body;
|
|
830
|
+
}
|
|
831
|
+
function sameChunkContext(left, right) {
|
|
832
|
+
return left.contextPrefix === right.contextPrefix
|
|
833
|
+
&& left.documentKind === right.documentKind
|
|
834
|
+
&& left.documentState === right.documentState
|
|
835
|
+
&& left.state === right.state
|
|
836
|
+
&& left.dated === right.dated;
|
|
837
|
+
}
|
|
838
|
+
function splitOversizedMarkdownChunk(chunk, maxChars = DEFAULT_EMBEDDING_MAX_CHARS, targetChars = DEFAULT_EMBEDDING_TARGET_CHARS) {
|
|
839
|
+
if (chunk.length <= maxChars) {
|
|
677
840
|
return [chunk];
|
|
678
841
|
}
|
|
679
842
|
const pieces = [];
|
|
680
843
|
let start = 0;
|
|
681
844
|
while (start < chunk.length) {
|
|
682
845
|
const remaining = chunk.length - start;
|
|
683
|
-
if (remaining <=
|
|
846
|
+
if (remaining <= maxChars) {
|
|
684
847
|
pieces.push(chunk.slice(start).trim());
|
|
685
848
|
break;
|
|
686
849
|
}
|
|
687
|
-
const preferredSplit = findPreferredChunkBoundary(chunk, start, Math.min(start +
|
|
850
|
+
const preferredSplit = findPreferredChunkBoundary(chunk, start, Math.min(start + targetChars, chunk.length), Math.min(start + maxChars, chunk.length), targetChars);
|
|
688
851
|
pieces.push(chunk.slice(start, preferredSplit).trim());
|
|
689
852
|
start = preferredSplit;
|
|
690
853
|
while (start < chunk.length && /\s/.test(chunk[start] ?? "")) {
|
|
@@ -693,8 +856,8 @@ function splitOversizedMarkdownChunk(chunk) {
|
|
|
693
856
|
}
|
|
694
857
|
return pieces.filter((piece) => piece.length > 0);
|
|
695
858
|
}
|
|
696
|
-
function findPreferredChunkBoundary(text, start, preferredEnd, hardEnd) {
|
|
697
|
-
const lowerBound = Math.max(start + Math.floor(
|
|
859
|
+
function findPreferredChunkBoundary(text, start, preferredEnd, hardEnd, targetChars = DEFAULT_EMBEDDING_TARGET_CHARS) {
|
|
860
|
+
const lowerBound = Math.max(start + Math.floor(targetChars / 2), start + 1);
|
|
698
861
|
for (let index = preferredEnd; index >= lowerBound; index -= 1) {
|
|
699
862
|
const current = text[index];
|
|
700
863
|
const previous = text[index - 1];
|
|
@@ -745,10 +908,10 @@ function runEmbeddingWorker(input) {
|
|
|
745
908
|
function resolveEmbeddingWorkerTimeoutMs() {
|
|
746
909
|
const raw = process.env.CLAW_EMBEDDING_WORKER_TIMEOUT_MS?.trim();
|
|
747
910
|
if (!raw) {
|
|
748
|
-
return
|
|
911
|
+
return 2 * 60 * 60 * 1000;
|
|
749
912
|
}
|
|
750
913
|
const parsed = Number(raw);
|
|
751
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed :
|
|
914
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 60 * 60 * 1000;
|
|
752
915
|
}
|
|
753
916
|
function stripBom(content) {
|
|
754
917
|
return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
|
|
@@ -804,6 +967,7 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
|
|
|
804
967
|
throw new ClawError("MEMORY_VECTOR_INDEX_REQUIRED", "Project search requires a refreshed vector index. Run `claw search index --refresh` first.");
|
|
805
968
|
}
|
|
806
969
|
const queryIntent = buildProjectQueryIntent(query);
|
|
970
|
+
const temporalIntent = detectTemporalQueryIntent(query);
|
|
807
971
|
const candidateLimit = Math.max(limit * PROJECT_SEARCH_CANDIDATE_MULTIPLIER, 40);
|
|
808
972
|
const projectDocs = db
|
|
809
973
|
.prepare("SELECT source_path, kind, content FROM docs")
|
|
@@ -839,7 +1003,7 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
|
|
|
839
1003
|
const queryEmbedding = resolveProjectQueryEmbedding(db, embedding, queryIntent.embeddingText || query);
|
|
840
1004
|
const vectorRows = db
|
|
841
1005
|
.prepare([
|
|
842
|
-
"SELECT source_path, kind, chunk_text, embedding_json",
|
|
1006
|
+
"SELECT source_path, kind, chunk_text, heading_path, document_kind, document_state, chunk_state, dated, embedding_json",
|
|
843
1007
|
"FROM doc_embeddings",
|
|
844
1008
|
].join(" "))
|
|
845
1009
|
.all();
|
|
@@ -855,30 +1019,48 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
|
|
|
855
1019
|
snippet: buildSnippet(row.chunk_text),
|
|
856
1020
|
similarity: cosineSimilarity(queryEmbedding.vector, parseEmbeddingJson(row.embedding_json)),
|
|
857
1021
|
exactBoost: signals?.exactBoost ?? 0,
|
|
1022
|
+
temporalBoost: calculateTemporalChunkBoost({
|
|
1023
|
+
intent: temporalIntent,
|
|
1024
|
+
documentState: row.document_state,
|
|
1025
|
+
state: row.chunk_state,
|
|
1026
|
+
dated: row.dated,
|
|
1027
|
+
}),
|
|
1028
|
+
documentKind: row.document_kind,
|
|
1029
|
+
documentState: row.document_state,
|
|
1030
|
+
state: row.chunk_state,
|
|
1031
|
+
dated: row.dated,
|
|
1032
|
+
headingPath: row.heading_path,
|
|
858
1033
|
};
|
|
859
1034
|
})
|
|
860
1035
|
.filter((row) => Number.isFinite(row.similarity))
|
|
861
1036
|
.sort((left, right) => {
|
|
862
|
-
const leftScore = left.similarity + left.exactBoost;
|
|
863
|
-
const rightScore = right.similarity + right.exactBoost;
|
|
1037
|
+
const leftScore = left.similarity + left.exactBoost + left.temporalBoost;
|
|
1038
|
+
const rightScore = right.similarity + right.exactBoost + right.temporalBoost;
|
|
864
1039
|
return rightScore - leftScore;
|
|
865
1040
|
});
|
|
866
1041
|
const bestVectorBySource = new Map();
|
|
867
1042
|
for (const row of rankedVectors) {
|
|
868
1043
|
const existing = bestVectorBySource.get(row.sourcePath);
|
|
869
|
-
|
|
1044
|
+
const vectorScore = row.similarity + row.temporalBoost;
|
|
1045
|
+
if (!existing || vectorScore > existing.vectorScore) {
|
|
870
1046
|
bestVectorBySource.set(row.sourcePath, {
|
|
871
1047
|
kind: row.kind,
|
|
872
1048
|
snippet: row.snippet,
|
|
873
1049
|
similarity: row.similarity,
|
|
1050
|
+
vectorScore,
|
|
1051
|
+
documentKind: row.documentKind,
|
|
1052
|
+
documentState: row.documentState,
|
|
1053
|
+
state: row.state,
|
|
1054
|
+
dated: row.dated,
|
|
1055
|
+
headingPath: row.headingPath,
|
|
874
1056
|
});
|
|
875
1057
|
}
|
|
876
1058
|
}
|
|
877
1059
|
const fused = new Map();
|
|
878
1060
|
Array.from(bestVectorBySource.entries())
|
|
879
1061
|
.sort((left, right) => {
|
|
880
|
-
const leftScore = left[1].
|
|
881
|
-
const rightScore = right[1].
|
|
1062
|
+
const leftScore = left[1].vectorScore + (docSignals.get(left[0])?.exactBoost ?? 0);
|
|
1063
|
+
const rightScore = right[1].vectorScore + (docSignals.get(right[0])?.exactBoost ?? 0);
|
|
882
1064
|
return rightScore - leftScore;
|
|
883
1065
|
})
|
|
884
1066
|
.slice(0, candidateLimit)
|
|
@@ -888,14 +1070,19 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
|
|
|
888
1070
|
sourcePath,
|
|
889
1071
|
kind: row.kind,
|
|
890
1072
|
snippet: row.snippet,
|
|
891
|
-
score: reciprocalRankScore(index + 1, 0.
|
|
1073
|
+
score: reciprocalRankScore(index + 1, 0.5) + (signals?.exactBoost ?? 0),
|
|
892
1074
|
vectorRank: index + 1,
|
|
1075
|
+
documentKind: row.documentKind,
|
|
1076
|
+
documentState: row.documentState,
|
|
1077
|
+
state: row.state,
|
|
1078
|
+
dated: row.dated,
|
|
1079
|
+
headingPath: row.headingPath,
|
|
893
1080
|
});
|
|
894
1081
|
});
|
|
895
1082
|
ftsRows.forEach((row, index) => {
|
|
896
1083
|
const existing = fused.get(row.source_path);
|
|
897
1084
|
const signals = docSignals.get(row.source_path);
|
|
898
|
-
const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.
|
|
1085
|
+
const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.3);
|
|
899
1086
|
fused.set(row.source_path, {
|
|
900
1087
|
sourcePath: row.source_path,
|
|
901
1088
|
kind: existing?.kind ?? row.kind,
|
|
@@ -903,12 +1090,13 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
|
|
|
903
1090
|
score: nextScore + (existing ? 0 : (signals?.exactBoost ?? 0)),
|
|
904
1091
|
vectorRank: existing?.vectorRank,
|
|
905
1092
|
textRank: index + 1,
|
|
1093
|
+
...(existing ? pickTemporalResultMetadata(existing) : {}),
|
|
906
1094
|
});
|
|
907
1095
|
});
|
|
908
1096
|
signalRows.forEach((row, index) => {
|
|
909
1097
|
const existing = fused.get(row.source_path);
|
|
910
1098
|
const signals = docSignals.get(row.source_path);
|
|
911
|
-
const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.
|
|
1099
|
+
const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.2);
|
|
912
1100
|
fused.set(row.source_path, {
|
|
913
1101
|
sourcePath: row.source_path,
|
|
914
1102
|
kind: existing?.kind ?? row.kind,
|
|
@@ -917,6 +1105,7 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
|
|
|
917
1105
|
vectorRank: existing?.vectorRank,
|
|
918
1106
|
textRank: existing?.textRank,
|
|
919
1107
|
signalRank: index + 1,
|
|
1108
|
+
...(existing ? pickTemporalResultMetadata(existing) : {}),
|
|
920
1109
|
});
|
|
921
1110
|
});
|
|
922
1111
|
return {
|
|
@@ -931,21 +1120,33 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
|
|
|
931
1120
|
function tryProjectLexicalFastPath(input) {
|
|
932
1121
|
const { queryIntent } = input;
|
|
933
1122
|
const primaryKeywordStep = buildProjectKeywordSearchPlan(input.query)[0];
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1123
|
+
const normalizedFileQuery = path.basename(input.query.trim()).toLowerCase();
|
|
1124
|
+
const normalizedFileStem = path.parse(normalizedFileQuery).name;
|
|
1125
|
+
const explicitFileMatches = /\.[a-z0-9]{1,8}$/iu.test(normalizedFileQuery)
|
|
1126
|
+
? Array.from(input.docSignals.keys()).filter((sourcePath) => {
|
|
1127
|
+
const candidateName = path.basename(sourcePath).toLowerCase();
|
|
1128
|
+
return candidateName === normalizedFileQuery
|
|
1129
|
+
|| path.parse(candidateName).name === normalizedFileStem;
|
|
1130
|
+
})
|
|
1131
|
+
: [];
|
|
1132
|
+
if (explicitFileMatches.length !== 1
|
|
1133
|
+
&& (queryIntent.strongTerms.length === 0
|
|
1134
|
+
|| queryIntent.weakTerms.length > 0
|
|
1135
|
+
|| !primaryKeywordStep
|
|
1136
|
+
|| primaryKeywordStep.substringTerms.length > 0)) {
|
|
938
1137
|
return null;
|
|
939
1138
|
}
|
|
940
1139
|
const fullCoverage = Array.from(input.docSignals.entries()).filter(([, signals]) => signals.strongCoverageRatio === 1);
|
|
941
1140
|
const pathMatches = fullCoverage.filter(([, signals]) => signals.fileNameHits >= queryIntent.strongTerms.length
|
|
942
1141
|
|| signals.pathHits >= queryIntent.strongTerms.length);
|
|
943
1142
|
const phraseMatches = fullCoverage.filter(([, signals]) => signals.phraseMatch);
|
|
944
|
-
const confidentSourcePath =
|
|
945
|
-
?
|
|
946
|
-
:
|
|
947
|
-
?
|
|
948
|
-
:
|
|
1143
|
+
const confidentSourcePath = explicitFileMatches.length === 1
|
|
1144
|
+
? explicitFileMatches[0]
|
|
1145
|
+
: pathMatches.length === 1
|
|
1146
|
+
? pathMatches[0]?.[0]
|
|
1147
|
+
: phraseMatches.length === 1
|
|
1148
|
+
? phraseMatches[0]?.[0]
|
|
1149
|
+
: null;
|
|
949
1150
|
if (!confidentSourcePath) {
|
|
950
1151
|
return null;
|
|
951
1152
|
}
|
|
@@ -1172,6 +1373,53 @@ function matchesAllSubstrings(db, sourcePath, substringTerms) {
|
|
|
1172
1373
|
function escapeLikePattern(term) {
|
|
1173
1374
|
return term.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
1174
1375
|
}
|
|
1376
|
+
function detectTemporalQueryIntent(query) {
|
|
1377
|
+
const normalized = query.trim().toLowerCase();
|
|
1378
|
+
const date = /\b(\d{4}-\d{2}-\d{2})\b/u.exec(normalized)?.[1] ?? null;
|
|
1379
|
+
const historical = /(?:\bwhy\b|\brationale\b|\bhistor(?:y|ical)\b|\bprevious(?:ly)?\b|\bpast\b|\bformerly\b|\brollback\b|\brevert(?:ed)?\b|\bused to\b|为什么|原因|历史|过去|之前|曾经|当时|回退|回滚|反复|演化|旧方案)/iu.test(normalized);
|
|
1380
|
+
if (historical || date) {
|
|
1381
|
+
return { mode: "historical", date };
|
|
1382
|
+
}
|
|
1383
|
+
const current = /(?:\bcurrent(?:ly)?\b|\bnow\b|\bpresent\b|\bfinal\b|\blatest\b|\bas of today\b|当前|现在|目前|最终|现行|如今|为准|怎么工作|如何工作)/iu.test(normalized);
|
|
1384
|
+
return { mode: current ? "current" : "neutral", date };
|
|
1385
|
+
}
|
|
1386
|
+
function calculateTemporalChunkBoost(input) {
|
|
1387
|
+
const effectiveState = input.state ?? input.documentState;
|
|
1388
|
+
let score = 0;
|
|
1389
|
+
if (input.intent.mode === "current") {
|
|
1390
|
+
if (effectiveState === "current" || effectiveState === "accepted")
|
|
1391
|
+
score += 0.035;
|
|
1392
|
+
else if (effectiveState === "historical")
|
|
1393
|
+
score -= 0.025;
|
|
1394
|
+
else if (effectiveState === "superseded")
|
|
1395
|
+
score -= 0.04;
|
|
1396
|
+
}
|
|
1397
|
+
else if (input.intent.mode === "historical") {
|
|
1398
|
+
if (effectiveState === "historical")
|
|
1399
|
+
score += 0.035;
|
|
1400
|
+
else if (effectiveState === "superseded")
|
|
1401
|
+
score += 0.02;
|
|
1402
|
+
}
|
|
1403
|
+
else if (effectiveState === "current" || effectiveState === "accepted") {
|
|
1404
|
+
score += 0.012;
|
|
1405
|
+
}
|
|
1406
|
+
else if (effectiveState === "superseded") {
|
|
1407
|
+
score -= 0.025;
|
|
1408
|
+
}
|
|
1409
|
+
if (input.intent.date && input.dated === input.intent.date) {
|
|
1410
|
+
score += 0.05;
|
|
1411
|
+
}
|
|
1412
|
+
return score;
|
|
1413
|
+
}
|
|
1414
|
+
function pickTemporalResultMetadata(entry) {
|
|
1415
|
+
return {
|
|
1416
|
+
...(entry.documentKind !== undefined ? { documentKind: entry.documentKind } : {}),
|
|
1417
|
+
...(entry.documentState !== undefined ? { documentState: entry.documentState } : {}),
|
|
1418
|
+
...(entry.state !== undefined ? { state: entry.state } : {}),
|
|
1419
|
+
...(entry.dated !== undefined ? { dated: entry.dated } : {}),
|
|
1420
|
+
...(entry.headingPath !== undefined ? { headingPath: entry.headingPath } : {}),
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1175
1423
|
function rerankProjectSearchCandidates(candidates, docSignals, limit) {
|
|
1176
1424
|
const remaining = [...candidates];
|
|
1177
1425
|
const selected = [];
|
|
@@ -1187,8 +1435,8 @@ function rerankProjectSearchCandidates(candidates, docSignals, limit) {
|
|
|
1187
1435
|
const uncoveredStrongTerms = (signals?.strongMatchedTerms ?? []).filter((term) => !coveredStrongTerms.has(term));
|
|
1188
1436
|
const uncoveredTerms = (signals?.matchedTerms ?? []).filter((term) => !coveredTerms.has(term));
|
|
1189
1437
|
const adjustedScore = candidate.score
|
|
1190
|
-
+ uncoveredStrongTerms.length * 0.
|
|
1191
|
-
+ uncoveredTerms.length * 0.
|
|
1438
|
+
+ uncoveredStrongTerms.length * 0.015
|
|
1439
|
+
+ uncoveredTerms.length * 0.003
|
|
1192
1440
|
+ Math.max(routeCount - 1, 0) * 0.003;
|
|
1193
1441
|
if (adjustedScore > bestScore) {
|
|
1194
1442
|
bestScore = adjustedScore;
|
|
@@ -1224,6 +1472,27 @@ function rerankProjectSearchCandidates(candidates, docSignals, limit) {
|
|
|
1224
1472
|
kind: next.kind,
|
|
1225
1473
|
snippet: next.snippet,
|
|
1226
1474
|
score: next.score,
|
|
1475
|
+
...pickTemporalResultMetadata(next),
|
|
1476
|
+
...(process.env.CLAW_SEARCH_RANKING_DIAGNOSTICS === "1"
|
|
1477
|
+
? {
|
|
1478
|
+
_ranking: {
|
|
1479
|
+
vectorRank: next.vectorRank ?? null,
|
|
1480
|
+
textRank: next.textRank ?? null,
|
|
1481
|
+
signalRank: next.signalRank ?? null,
|
|
1482
|
+
baseLexicalBoost: (nextSignals?.exactBoost ?? 0)
|
|
1483
|
+
- (nextSignals?.titleMatchScore ?? 0)
|
|
1484
|
+
- (nextSignals?.entityTitleScore ?? 0)
|
|
1485
|
+
- (nextSignals?.documentTypeScore ?? 0)
|
|
1486
|
+
+ (nextSignals?.genericPenalty ?? 0),
|
|
1487
|
+
titleMatchScore: nextSignals?.titleMatchScore ?? 0,
|
|
1488
|
+
entityTitleScore: nextSignals?.entityTitleScore ?? 0,
|
|
1489
|
+
documentTypeScore: nextSignals?.documentTypeScore ?? 0,
|
|
1490
|
+
genericPenalty: nextSignals?.genericPenalty ?? 0,
|
|
1491
|
+
matchedTerms: nextSignals?.matchedTerms ?? [],
|
|
1492
|
+
strongMatchedTerms: nextSignals?.strongMatchedTerms ?? [],
|
|
1493
|
+
},
|
|
1494
|
+
}
|
|
1495
|
+
: {}),
|
|
1227
1496
|
});
|
|
1228
1497
|
}
|
|
1229
1498
|
return selected;
|
|
@@ -1233,6 +1502,7 @@ function buildProjectSearchSignals(input) {
|
|
|
1233
1502
|
const normalizedContent = input.content.toLowerCase();
|
|
1234
1503
|
const normalizedPath = input.sourcePath.toLowerCase();
|
|
1235
1504
|
const fileName = path.basename(input.sourcePath).toLowerCase();
|
|
1505
|
+
const title = extractDocumentTitle(input.content, fileName);
|
|
1236
1506
|
const lowerTerms = input.queryIntent.terms.map((term) => term.toLowerCase());
|
|
1237
1507
|
const lowerStrongTerms = input.queryIntent.strongTerms.map((term) => term.toLowerCase());
|
|
1238
1508
|
const lowerWeakTerms = input.queryIntent.weakTerms.map((term) => term.toLowerCase());
|
|
@@ -1251,12 +1521,11 @@ function buildProjectSearchSignals(input) {
|
|
|
1251
1521
|
const phraseMatch = normalizedQuery.length > 0
|
|
1252
1522
|
&& (normalizedContent.includes(normalizedQuery.toLowerCase()) || normalizedPath.includes(normalizedQuery.toLowerCase()));
|
|
1253
1523
|
const weakOnlyPenalty = strongMatchedTerms.size === 0 && weakMatchedTerms.size > 0 ? 0.012 : 0;
|
|
1254
|
-
const missingStrongPenalty = lowerStrongTerms.length > 0 && strongMatchedTerms.size === 0
|
|
1255
|
-
? 0.035
|
|
1256
|
-
: lowerStrongTerms.length > 1 && strongMatchedTerms.size === 1
|
|
1257
|
-
? 0.01
|
|
1258
|
-
: 0;
|
|
1259
1524
|
const indexFilePenalty = isIndexLikeDocName(fileName) ? 0.06 : 0;
|
|
1525
|
+
const titleMatchScore = calculateTitleMatchScore(normalizedQuery, title);
|
|
1526
|
+
const entityTitleScore = calculateEntityTitleScore(input.queryIntent.entityPhrases, title);
|
|
1527
|
+
const documentTypeScore = calculateDocumentTypeScore(normalizedQuery, input.sourcePath, title);
|
|
1528
|
+
const genericPenalty = calculateGenericDocumentPenalty(normalizedQuery, input.sourcePath, fileName, title);
|
|
1260
1529
|
return {
|
|
1261
1530
|
matchedTerms: Array.from(matchedTerms),
|
|
1262
1531
|
strongMatchedTerms: Array.from(strongMatchedTerms),
|
|
@@ -1267,6 +1536,10 @@ function buildProjectSearchSignals(input) {
|
|
|
1267
1536
|
pathHits,
|
|
1268
1537
|
phraseMatch,
|
|
1269
1538
|
strongCoverageRatio,
|
|
1539
|
+
titleMatchScore,
|
|
1540
|
+
entityTitleScore,
|
|
1541
|
+
documentTypeScore,
|
|
1542
|
+
genericPenalty,
|
|
1270
1543
|
exactBoost: strongMatchedTerms.size * 0.016
|
|
1271
1544
|
+ weakMatchedTerms.size * 0.004
|
|
1272
1545
|
+ coverageRatio * 0.008
|
|
@@ -1275,13 +1548,117 @@ function buildProjectSearchSignals(input) {
|
|
|
1275
1548
|
+ fileNameHits * 0.025
|
|
1276
1549
|
+ pathHits * 0.01
|
|
1277
1550
|
+ (phraseMatch ? 0.018 : 0)
|
|
1551
|
+
+ titleMatchScore
|
|
1552
|
+
+ entityTitleScore
|
|
1553
|
+
+ documentTypeScore
|
|
1278
1554
|
- weakOnlyPenalty
|
|
1279
|
-
-
|
|
1280
|
-
-
|
|
1555
|
+
- indexFilePenalty
|
|
1556
|
+
- genericPenalty,
|
|
1281
1557
|
};
|
|
1282
1558
|
}
|
|
1559
|
+
function calculateEntityTitleScore(entityPhrases, title) {
|
|
1560
|
+
const normalizedTitle = normalizeComparableTitle(title);
|
|
1561
|
+
let score = 0;
|
|
1562
|
+
for (const phrase of entityPhrases) {
|
|
1563
|
+
const normalizedPhrase = normalizeComparableTitle(phrase);
|
|
1564
|
+
if (normalizedPhrase.length < 3) {
|
|
1565
|
+
continue;
|
|
1566
|
+
}
|
|
1567
|
+
if (normalizedPhrase === normalizedTitle) {
|
|
1568
|
+
score = Math.max(score, 0.14);
|
|
1569
|
+
}
|
|
1570
|
+
else if (normalizedTitle.includes(normalizedPhrase) || normalizedPhrase.includes(normalizedTitle)) {
|
|
1571
|
+
score = Math.max(score, 0.09);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return score;
|
|
1575
|
+
}
|
|
1576
|
+
function normalizeComparableTitle(value) {
|
|
1577
|
+
return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
1578
|
+
}
|
|
1283
1579
|
function isIndexLikeDocName(fileName) {
|
|
1284
|
-
return fileName === "contents.md"
|
|
1580
|
+
return fileName === "contents.md"
|
|
1581
|
+
|| fileName === "summary.md"
|
|
1582
|
+
|| fileName === "index.md"
|
|
1583
|
+
|| fileName === "readme.md"
|
|
1584
|
+
|| fileName === "project-truth.md";
|
|
1585
|
+
}
|
|
1586
|
+
function extractDocumentTitle(content, fileName) {
|
|
1587
|
+
const heading = content
|
|
1588
|
+
.split(/\r?\n/u, 40)
|
|
1589
|
+
.find((line) => /^#{1,3}\s+\S/u.test(line.trim()));
|
|
1590
|
+
return (heading?.replace(/^#{1,3}\s+/u, "") ?? path.parse(fileName).name).trim().toLowerCase();
|
|
1591
|
+
}
|
|
1592
|
+
function calculateTitleMatchScore(query, title) {
|
|
1593
|
+
const queryFeatures = buildTitleLexicalFeatures(query);
|
|
1594
|
+
const titleFeatures = buildTitleLexicalFeatures(title);
|
|
1595
|
+
if (queryFeatures.size === 0 || titleFeatures.size === 0) {
|
|
1596
|
+
return 0;
|
|
1597
|
+
}
|
|
1598
|
+
let matched = 0;
|
|
1599
|
+
for (const feature of titleFeatures) {
|
|
1600
|
+
if (queryFeatures.has(feature)) {
|
|
1601
|
+
matched += 1;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
if (matched < 2) {
|
|
1605
|
+
return 0;
|
|
1606
|
+
}
|
|
1607
|
+
const titleCoverage = matched / titleFeatures.size;
|
|
1608
|
+
return Math.min(0.075, matched * 0.006 + titleCoverage * 0.035);
|
|
1609
|
+
}
|
|
1610
|
+
function buildTitleLexicalFeatures(value) {
|
|
1611
|
+
const features = new Set();
|
|
1612
|
+
for (const asciiWord of value.toLowerCase().match(/[a-z][a-z0-9_-]{2,}/gu) ?? []) {
|
|
1613
|
+
features.add(`a:${asciiWord}`);
|
|
1614
|
+
}
|
|
1615
|
+
for (const sequence of value.match(/[\u4e00-\u9fff]+/gu) ?? []) {
|
|
1616
|
+
const characters = Array.from(sequence);
|
|
1617
|
+
for (let size = 2; size <= 3; size += 1) {
|
|
1618
|
+
for (let index = 0; index <= characters.length - size; index += 1) {
|
|
1619
|
+
features.add(`c:${characters.slice(index, index + size).join("")}`);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return features;
|
|
1624
|
+
}
|
|
1625
|
+
function calculateDocumentTypeScore(query, sourcePath, title) {
|
|
1626
|
+
const normalizedPath = sourcePath.replaceAll("\\", "/").toLowerCase();
|
|
1627
|
+
let score = 0;
|
|
1628
|
+
if (/(?:必须|不应该|优先|规则)/u.test(query) && /(?:规则|规范|指南)/u.test(title)) {
|
|
1629
|
+
score += 0.025;
|
|
1630
|
+
}
|
|
1631
|
+
if (/(?:有哪些|哪些|名单|清单|在哪里定义|哪里定义)/u.test(query) && /(?:名单|清单|目录|索引)/u.test(title)) {
|
|
1632
|
+
score += 0.025;
|
|
1633
|
+
}
|
|
1634
|
+
if (/(?:章节|第[一二三四五六七八九十百]+章)/u.test(query) && /第[一二三四五六七八九十百]+章/u.test(title)) {
|
|
1635
|
+
score += 0.025;
|
|
1636
|
+
}
|
|
1637
|
+
if (/(?:边界|允许|禁止|分工)/u.test(query) && /(?:边界|规范|规则|分工)/u.test(title)) {
|
|
1638
|
+
score += 0.02;
|
|
1639
|
+
}
|
|
1640
|
+
if (/(?:为什么|为何|决定|取舍)/u.test(query) && /(?:adr|方案|决策|裁定)/iu.test(title)) {
|
|
1641
|
+
score += 0.012;
|
|
1642
|
+
}
|
|
1643
|
+
if (/(?:现在|当前|最终|由谁|运行时)/u.test(query) && normalizedPath.includes("/.claw/truth/")) {
|
|
1644
|
+
score += 0.006;
|
|
1645
|
+
}
|
|
1646
|
+
return Math.min(score, 0.04);
|
|
1647
|
+
}
|
|
1648
|
+
function calculateGenericDocumentPenalty(query, sourcePath, fileName, title) {
|
|
1649
|
+
const normalizedPath = sourcePath.replaceAll("\\", "/").toLowerCase();
|
|
1650
|
+
const explicitlyRequestsPlanning = /(?:计划|规划|路线|规格|实施方案|复盘)/u.test(query);
|
|
1651
|
+
const planningLikePath = /\/(?:plans?|specs?|development-plans|[^/]*开发[^/]*计划)(?:\/|$)/iu.test(normalizedPath);
|
|
1652
|
+
if (planningLikePath && !explicitlyRequestsPlanning) {
|
|
1653
|
+
return 0.04;
|
|
1654
|
+
}
|
|
1655
|
+
if (/第[一二三四五六七八九十百]+章/u.test(title) && !/(?:章节|第[一二三四五六七八九十百]+章)/u.test(query)) {
|
|
1656
|
+
return 0.025;
|
|
1657
|
+
}
|
|
1658
|
+
if (/(?:总纲|索引|总览)/u.test(title) && !/(?:总纲|索引|总览|整体|全部)/u.test(query)) {
|
|
1659
|
+
return 0.035;
|
|
1660
|
+
}
|
|
1661
|
+
return isIndexLikeDocName(fileName) ? 0.01 : 0;
|
|
1285
1662
|
}
|
|
1286
1663
|
function reciprocalRankScore(rank, weight) {
|
|
1287
1664
|
return weight * (1 / (40 + rank));
|