concierge-graph 3.8.2__py3-none-any.whl
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.
- agents/__init__.py +14 -0
- agents/revisor_critico.py +610 -0
- concierge_graph-3.8.2.dist-info/METADATA +327 -0
- concierge_graph-3.8.2.dist-info/RECORD +37 -0
- concierge_graph-3.8.2.dist-info/WHEEL +5 -0
- concierge_graph-3.8.2.dist-info/entry_points.txt +3 -0
- concierge_graph-3.8.2.dist-info/licenses/LICENSE +21 -0
- concierge_graph-3.8.2.dist-info/top_level.txt +8 -0
- core/__init__.py +23 -0
- core/config.py +192 -0
- core/hybrid_search.py +288 -0
- core/memory_extractor.py +245 -0
- core/middleware.py +723 -0
- core/probabilistic_retriever.py +99 -0
- core/project_index.py +316 -0
- core/vector_backend.py +471 -0
- ingestion/__init__.py +25 -0
- ingestion/crawler.py +722 -0
- ingestion/orchestrator.py +920 -0
- ingestion/parser.py +984 -0
- ingestion/summarizer.py +948 -0
- interface/__init__.py +16 -0
- interface/action_hooks.py +261 -0
- interface/cli.py +391 -0
- interface/mcp_server.py +1737 -0
- main.py +281 -0
- scripts/bootstrap_core_memory.py +93 -0
- services/__init__.py +13 -0
- services/janitor.py +762 -0
- storage/__init__.py +31 -0
- storage/base_backend.py +241 -0
- storage/connection.py +310 -0
- storage/logic.py +703 -0
- storage/schema.py +390 -0
- storage/semantic_logic.py +125 -0
- storage/store.py +802 -0
- storage/vector_store.py +759 -0
core/hybrid_search.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
core/hybrid_search.py — Grafo Concierge v3.8.0 (Absolute Solidity)
|
|
3
|
+
|
|
4
|
+
Hybrid Search Engine v4 — Complete orchestration of the search pipeline.
|
|
5
|
+
|
|
6
|
+
This module is the heart of memory retrieval. It orchestrates the three
|
|
7
|
+
relevance signals and combines them in the official weighted formula:
|
|
8
|
+
|
|
9
|
+
score = (0.50 × vector)
|
|
10
|
+
+ (0.25 × normalized_fts5)
|
|
11
|
+
+ (0.25 × max(recency, centrality))
|
|
12
|
+
|
|
13
|
+
Pipeline flow:
|
|
14
|
+
1. STRICT SCOPING — ProjectIndex resolves which project_uuids are
|
|
15
|
+
in scope (Primary Wing, +References, or All Wings).
|
|
16
|
+
2. EMBEDDING — EmbeddingManager generates the query vector.
|
|
17
|
+
3. VECTOR SEARCH — ChromaVectorStore.search() returns candidates
|
|
18
|
+
with cosine similarity scores.
|
|
19
|
+
4. FTS5 SEARCH — SqliteStore.fts_search() returns candidates with
|
|
20
|
+
normalized BM25 scores.
|
|
21
|
+
5. MERGE — Merges both candidate sets by node_id.
|
|
22
|
+
6. HYBRID SCORE — GraphLogic.hybrid_search_score_batch() calculates
|
|
23
|
+
the final weighted score.
|
|
24
|
+
7. SORT — Returns sorted by final_score DESC.
|
|
25
|
+
|
|
26
|
+
Usage & Integration:
|
|
27
|
+
- core.project_index.ProjectIndex → Strict Scoping
|
|
28
|
+
- storage.vector_store.ChromaVectorStore → Vector search
|
|
29
|
+
- storage.vector_store.EmbeddingManager → Embedding generation
|
|
30
|
+
- storage.store.SqliteStore → FTS5 + Hybrid Score Batch
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import logging
|
|
36
|
+
from typing import Any, Optional
|
|
37
|
+
|
|
38
|
+
from core.config import ConciergeConfig, DEFAULT_CONFIG
|
|
39
|
+
from core.project_index import ProjectIndex
|
|
40
|
+
from storage.store import SqliteStore
|
|
41
|
+
from storage.vector_store import ChromaVectorStore, EmbeddingManager
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("grafo-concierge.hybrid-search")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HybridSearchEngine:
|
|
47
|
+
"""Hybrid Search Engine v4 — complete pipeline.
|
|
48
|
+
|
|
49
|
+
Combines vector search, FTS5, and Max(Recency, Centrality)
|
|
50
|
+
into a single optimized flow.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
sqlite_store: SQLite facade for FTS5 and score batch.
|
|
54
|
+
vector_store: Vector backend for cosine similarity.
|
|
55
|
+
embedding_manager: Query embedding generator.
|
|
56
|
+
project_index: Knowledge GPS for Strict Scoping.
|
|
57
|
+
config: System parameters.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
sqlite_store: SqliteStore,
|
|
63
|
+
vector_store: ChromaVectorStore,
|
|
64
|
+
embedding_manager: EmbeddingManager,
|
|
65
|
+
project_index: ProjectIndex,
|
|
66
|
+
config: ConciergeConfig = DEFAULT_CONFIG,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._store = sqlite_store
|
|
69
|
+
self._vector = vector_store
|
|
70
|
+
self._embedder = embedding_manager
|
|
71
|
+
self._project_index = project_index
|
|
72
|
+
self._config = config
|
|
73
|
+
|
|
74
|
+
# ===================================================================
|
|
75
|
+
# SEARCH — Main Entry Point
|
|
76
|
+
# ===================================================================
|
|
77
|
+
|
|
78
|
+
def search(
|
|
79
|
+
self,
|
|
80
|
+
query: str,
|
|
81
|
+
project_uuid: str,
|
|
82
|
+
top_k: Optional[int] = None,
|
|
83
|
+
include_references: bool = False,
|
|
84
|
+
all_wings: bool = False,
|
|
85
|
+
node_type: Optional[str] = None,
|
|
86
|
+
enable_probabilistic: bool = False,
|
|
87
|
+
) -> list[dict]:
|
|
88
|
+
"""Executes the complete Hybrid Search v4.
|
|
89
|
+
|
|
90
|
+
This method implements the 8-step pipeline described in
|
|
91
|
+
Architecture v3.8.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
query: Search text (natural or technical language).
|
|
95
|
+
project_uuid: Anchor project UUID.
|
|
96
|
+
top_k: Maximum final results (default: config.search_top_k).
|
|
97
|
+
include_references: Include Reference Wings in scope.
|
|
98
|
+
all_wings: Search in all wings (ignores Strict Scoping).
|
|
99
|
+
node_type: Surgical filter (FACT, SKILL, INSIGHT, etc.).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of dicts with final_score and complete breakdown,
|
|
103
|
+
sorted by relevance (DESC).
|
|
104
|
+
Each dict contains:
|
|
105
|
+
{
|
|
106
|
+
"node_id": int,
|
|
107
|
+
"score_final": float,
|
|
108
|
+
"score_breakdown": {
|
|
109
|
+
"vector": float,
|
|
110
|
+
"frequency": float,
|
|
111
|
+
"recency": float,
|
|
112
|
+
"centrality": float,
|
|
113
|
+
},
|
|
114
|
+
"is_super_node": bool,
|
|
115
|
+
}
|
|
116
|
+
"""
|
|
117
|
+
if top_k is None:
|
|
118
|
+
top_k = self._config.search_top_k
|
|
119
|
+
|
|
120
|
+
logger.info(
|
|
121
|
+
"Hybrid Search v4: query='%.50s...', project=%s, top_k=%d, "
|
|
122
|
+
"refs=%s, all_wings=%s, node_type=%s",
|
|
123
|
+
query, project_uuid, top_k,
|
|
124
|
+
include_references, all_wings, node_type,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# --- STEP 1: STRICT SCOPING ---
|
|
128
|
+
scoped_uuids = self._project_index.resolve_scoped_uuids(
|
|
129
|
+
project_uuid,
|
|
130
|
+
include_references=include_references,
|
|
131
|
+
all_wings=all_wings,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not scoped_uuids:
|
|
135
|
+
logger.warning("Strict Scoping returned 0 projects — empty search.")
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
logger.debug("Strict Scoping: %d projects in scope.", len(scoped_uuids))
|
|
139
|
+
|
|
140
|
+
# --- STEP 2: QUERY EMBEDDING ---
|
|
141
|
+
query_embedding = self._embedder.embed(query)
|
|
142
|
+
if query_embedding is None:
|
|
143
|
+
logger.error("Semantic Fallback: query embedding failed. Returning FTS-only.")
|
|
144
|
+
return self._fts_only_fallback(query, project_uuid, node_type, top_k)
|
|
145
|
+
|
|
146
|
+
# --- STEP 3: VECTOR SEARCH ---
|
|
147
|
+
vector_results = self._vector.search(
|
|
148
|
+
query_embedding=query_embedding,
|
|
149
|
+
project_uuids=scoped_uuids,
|
|
150
|
+
top_k=top_k * 3, # Searches 3x to have enough candidates
|
|
151
|
+
filters={"node_type": node_type} if node_type else None,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
logger.debug("Vector search returned %d candidates.", len(vector_results))
|
|
155
|
+
|
|
156
|
+
# Build dict of vector scores by node_id
|
|
157
|
+
# GROUP BY node_id, MAX(score) — as specified in Architecture
|
|
158
|
+
vector_scores: dict[int, float] = {}
|
|
159
|
+
for vr in vector_results:
|
|
160
|
+
if vr.node_id not in vector_scores or vr.score > vector_scores[vr.node_id]:
|
|
161
|
+
vector_scores[vr.node_id] = vr.score
|
|
162
|
+
|
|
163
|
+
# --- STEP 4: FTS5 SEARCH ---
|
|
164
|
+
fts_results = self._store.fts_search(
|
|
165
|
+
query=query,
|
|
166
|
+
project_uuid=project_uuid,
|
|
167
|
+
node_type=node_type,
|
|
168
|
+
limit=self._config.fts_limit,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
logger.debug("FTS5 search returned %d candidates.", len(fts_results))
|
|
172
|
+
|
|
173
|
+
# Build dict of FTS scores by node_id
|
|
174
|
+
fts_scores: dict[int, float] = {}
|
|
175
|
+
for fr in fts_results:
|
|
176
|
+
node_id = fr.get("id")
|
|
177
|
+
if node_id is not None:
|
|
178
|
+
fts_scores[node_id] = fr.get("bm25_score", 0.0)
|
|
179
|
+
|
|
180
|
+
# --- STEP 5: MERGE — Combine candidates from both signals ---
|
|
181
|
+
all_node_ids = set(vector_scores.keys()) | set(fts_scores.keys())
|
|
182
|
+
|
|
183
|
+
if not all_node_ids:
|
|
184
|
+
logger.info("No candidates found in both signals.")
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
candidates: list[dict] = []
|
|
188
|
+
for node_id in all_node_ids:
|
|
189
|
+
candidates.append({
|
|
190
|
+
"node_id": node_id,
|
|
191
|
+
"vector_score": vector_scores.get(node_id, 0.0),
|
|
192
|
+
"fts_score": fts_scores.get(node_id, 0.0),
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
logger.debug("Merge: %d unique candidates by node_id.", len(candidates))
|
|
196
|
+
|
|
197
|
+
# --- STEP 6: HYBRID SCORE ---
|
|
198
|
+
scored_results = self._store.hybrid_search_score_batch(candidates)
|
|
199
|
+
|
|
200
|
+
# --- STEP 7: THOMPSON SAMPLING (optional) ---
|
|
201
|
+
if enable_probabilistic:
|
|
202
|
+
scored_results = self._apply_thompson_sampling(scored_results)
|
|
203
|
+
|
|
204
|
+
# --- STEP 8: SORT and TRUNCATE ---
|
|
205
|
+
scored_results.sort(key=lambda x: x.get("score_final", 0), reverse=True)
|
|
206
|
+
final = scored_results[:top_k]
|
|
207
|
+
|
|
208
|
+
logger.info(
|
|
209
|
+
"Hybrid Search v4 complete: %d candidates → %d final results.",
|
|
210
|
+
len(candidates), len(final),
|
|
211
|
+
)
|
|
212
|
+
return final
|
|
213
|
+
|
|
214
|
+
# ===================================================================
|
|
215
|
+
# THOMPSON SAMPLING — Probabilistic multiplier (SA-CTS)
|
|
216
|
+
# ===================================================================
|
|
217
|
+
|
|
218
|
+
def _apply_thompson_sampling(self, results: list[dict]) -> list[dict]:
|
|
219
|
+
"""Applies Thompson sampling probabilistic multiplier to score_final.
|
|
220
|
+
|
|
221
|
+
For each candidate, gets utility_alpha and utility_beta from
|
|
222
|
+
node metadata and samples a multiplier via Beta Distribution.
|
|
223
|
+
The score_final is multiplied by this factor, balancing
|
|
224
|
+
exploration (rarely accessed nodes) and exploitation (proven nodes).
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
results: List of dicts with score_final and score_breakdown.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Same list with score_final adjusted by Thompson multiplier.
|
|
231
|
+
"""
|
|
232
|
+
from core.probabilistic_retriever import ThompsonRetriever
|
|
233
|
+
|
|
234
|
+
for item in results:
|
|
235
|
+
try:
|
|
236
|
+
node = self._store.get_node(item["node_id"])
|
|
237
|
+
alpha = float(node.get("utility_alpha", 1.0) or 1.0)
|
|
238
|
+
beta_param = float(node.get("utility_beta", 1.0) or 1.0)
|
|
239
|
+
except Exception:
|
|
240
|
+
alpha, beta_param = 1.0, 1.0
|
|
241
|
+
|
|
242
|
+
multiplier = ThompsonRetriever.sample_multiplier(alpha, beta_param)
|
|
243
|
+
item["score_final"] = round(item["score_final"] * multiplier, 4)
|
|
244
|
+
item["score_breakdown"]["thompson_multiplier"] = round(multiplier, 4)
|
|
245
|
+
|
|
246
|
+
return results
|
|
247
|
+
|
|
248
|
+
# ===================================================================
|
|
249
|
+
# FALLBACK — Busca apenas FTS5 quando embedding falha
|
|
250
|
+
# ===================================================================
|
|
251
|
+
|
|
252
|
+
def _fts_only_fallback(
|
|
253
|
+
self,
|
|
254
|
+
query: str,
|
|
255
|
+
project_uuid: str,
|
|
256
|
+
node_type: Optional[str],
|
|
257
|
+
top_k: int,
|
|
258
|
+
) -> list[dict]:
|
|
259
|
+
"""Fallback: returns results from FTS5 only.
|
|
260
|
+
|
|
261
|
+
Used when query embedding fails (Semantic Fallback).
|
|
262
|
+
The results will have no vector score, only BM25 + recency/centrality.
|
|
263
|
+
"""
|
|
264
|
+
logger.warning("FTS-only fallback active — results without vector component.")
|
|
265
|
+
|
|
266
|
+
fts_results = self._store.fts_search(
|
|
267
|
+
query=query,
|
|
268
|
+
project_uuid=project_uuid,
|
|
269
|
+
node_type=node_type,
|
|
270
|
+
limit=top_k,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if not fts_results:
|
|
274
|
+
return []
|
|
275
|
+
|
|
276
|
+
candidates = [
|
|
277
|
+
{
|
|
278
|
+
"node_id": fr["id"],
|
|
279
|
+
"vector_score": 0.0, # No vector component
|
|
280
|
+
"fts_score": fr.get("bm25_score", 0.0),
|
|
281
|
+
}
|
|
282
|
+
for fr in fts_results
|
|
283
|
+
if fr.get("id") is not None
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
scored = self._store.hybrid_search_score_batch(candidates)
|
|
287
|
+
scored.sort(key=lambda x: x.get("score_final", 0), reverse=True)
|
|
288
|
+
return scored[:top_k]
|
core/memory_extractor.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
core/memory_extractor.py — Grafo Concierge v3.8.0 (Absolute Solidity)
|
|
3
|
+
|
|
4
|
+
Semantic Extraction Engine and NOOP decision pipeline of the Hybrid Conversational Layer.
|
|
5
|
+
Ensures that new facts are classified and recorded in pure bi-temporal form (invalidation + insertion).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import sqlite3
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from storage.semantic_logic import insert_semantic_fact, invalidate_semantic_fact, get_active_semantic_facts
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("grafo-concierge.memory-extractor")
|
|
19
|
+
|
|
20
|
+
_JSON_BLOCK_RE = re.compile(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", re.DOTALL)
|
|
21
|
+
|
|
22
|
+
DECISION_PROMPT_TEMPLATE = """You are an AI memory manager. Compare the NEW FACT below against the list of EXISTING FACTS for this scope, and decide which action to take: ADD, UPDATE, DELETE, or NOOP.
|
|
23
|
+
|
|
24
|
+
EXISTING FACTS:
|
|
25
|
+
{existing_facts_block}
|
|
26
|
+
|
|
27
|
+
NEW FACT to evaluate:
|
|
28
|
+
"{new_fact}"
|
|
29
|
+
|
|
30
|
+
INSTRUCTIONS:
|
|
31
|
+
1. Choose "NOOP" if the NEW FACT is already fully covered by or redundant with the EXISTING FACTS, without adding any new or different information.
|
|
32
|
+
2. Choose "ADD" if the NEW FACT is completely new, unrelated to any of the EXISTING FACTS, and doesn't contradict or update them.
|
|
33
|
+
3. Choose "UPDATE" if the NEW FACT updates, refines, or adds details to an EXISTING FACT. You must provide the exact ID of the existing fact to update, and the new consolidated "updated_statement" that merges both facts cleanly (e.g. keeping it concise and natural).
|
|
34
|
+
4. Choose "DELETE" if the NEW FACT explicitly contradicts, negates, or revokes an EXISTING FACT. You must provide the exact ID of the existing fact to delete.
|
|
35
|
+
|
|
36
|
+
You MUST respond with a single, valid JSON object and nothing else. No markdown formatting, no backticks, no comments.
|
|
37
|
+
JSON format:
|
|
38
|
+
{{
|
|
39
|
+
"action": "ADD" | "UPDATE" | "DELETE" | "NOOP",
|
|
40
|
+
"target_id": integer ID or null,
|
|
41
|
+
"updated_statement": string or null
|
|
42
|
+
}}"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SemanticExtractor:
|
|
46
|
+
"""Processing and consolidation engine for semantic facts within memory scopes.
|
|
47
|
+
|
|
48
|
+
Uses an LLM adapter to classify new facts and decides whether to create them (ADD),
|
|
49
|
+
update them under bi-temporal logic (UPDATE), revoke them (DELETE), or ignore them (NOOP).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, llm_adapter: Any) -> None:
|
|
53
|
+
"""Initializes the memory extractor.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
llm_adapter: LLM adaptation object (e.g. LLMAdapter or equivalent mock).
|
|
57
|
+
"""
|
|
58
|
+
self.llm = llm_adapter
|
|
59
|
+
|
|
60
|
+
def evaluate_and_store_facts(
|
|
61
|
+
self,
|
|
62
|
+
conn: sqlite3.Connection,
|
|
63
|
+
scope_type: str,
|
|
64
|
+
scope_id: str,
|
|
65
|
+
new_facts: list[str]
|
|
66
|
+
) -> list[dict[str, Any]]:
|
|
67
|
+
"""Evaluates new facts and stores them according to NOOP / bi-temporal logic.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
conn: Direct connection to SQLite (used under a single transaction).
|
|
71
|
+
scope_type: Scope of the fact ('user', 'session', 'agent', 'org').
|
|
72
|
+
scope_id: Identifying ID of the scope.
|
|
73
|
+
new_facts: List of strings containing the raw memories to evaluate.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of dictionaries detailing the decisions made for each fact.
|
|
77
|
+
"""
|
|
78
|
+
results: list[dict[str, Any]] = []
|
|
79
|
+
|
|
80
|
+
for new_fact in new_facts:
|
|
81
|
+
new_fact = new_fact.strip()
|
|
82
|
+
if not new_fact:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
active_facts = get_active_semantic_facts(conn, scope_type, scope_id)
|
|
86
|
+
if not active_facts:
|
|
87
|
+
# Optimization: if there are no registered facts, it is obligatorily an ADD
|
|
88
|
+
fact_id = insert_semantic_fact(conn, scope_type, scope_id, new_fact)
|
|
89
|
+
results.append({
|
|
90
|
+
"fact": new_fact,
|
|
91
|
+
"action": "ADD",
|
|
92
|
+
"target_id": None,
|
|
93
|
+
"fact_id": fact_id
|
|
94
|
+
})
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
# Builds block of existing facts for injection into the prompt
|
|
98
|
+
existing_facts_block = "\n".join(
|
|
99
|
+
f"- ID {f['id']}: {f['fact_statement']}" for f in active_facts
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
prompt = DECISION_PROMPT_TEMPLATE.format(
|
|
103
|
+
existing_facts_block=existing_facts_block,
|
|
104
|
+
new_fact=new_fact
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
raw_response = self.llm.generate(prompt, max_tokens=300)
|
|
109
|
+
parsed = self._extract_json_with_fallback(raw_response)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.error("Falha ao chamar LLM para avaliar o fato '%s': %s", new_fact, e)
|
|
112
|
+
parsed = None
|
|
113
|
+
|
|
114
|
+
if not parsed or "action" not in parsed:
|
|
115
|
+
logger.warning("Falha ao parsear decisão estruturada para o fato '%s'. Executando ADD.", new_fact)
|
|
116
|
+
fact_id = insert_semantic_fact(conn, scope_type, scope_id, new_fact)
|
|
117
|
+
results.append({
|
|
118
|
+
"fact": new_fact,
|
|
119
|
+
"action": "ADD",
|
|
120
|
+
"target_id": None,
|
|
121
|
+
"fact_id": fact_id,
|
|
122
|
+
"fallback": True
|
|
123
|
+
})
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
action = parsed.get("action")
|
|
127
|
+
target_id = parsed.get("target_id")
|
|
128
|
+
updated_statement = parsed.get("updated_statement")
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
t_id = int(target_id) if target_id is not None else None
|
|
132
|
+
except (ValueError, TypeError):
|
|
133
|
+
t_id = None
|
|
134
|
+
|
|
135
|
+
active_ids = {f["id"] for f in active_facts}
|
|
136
|
+
|
|
137
|
+
if action == "NOOP":
|
|
138
|
+
results.append({
|
|
139
|
+
"fact": new_fact,
|
|
140
|
+
"action": "NOOP",
|
|
141
|
+
"target_id": None,
|
|
142
|
+
"fact_id": None
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
elif action == "ADD":
|
|
146
|
+
fact_id = insert_semantic_fact(conn, scope_type, scope_id, new_fact)
|
|
147
|
+
results.append({
|
|
148
|
+
"fact": new_fact,
|
|
149
|
+
"action": "ADD",
|
|
150
|
+
"target_id": None,
|
|
151
|
+
"fact_id": fact_id
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
elif action == "UPDATE":
|
|
155
|
+
if t_id is None or t_id not in active_ids:
|
|
156
|
+
# Invalid/non-existent ID in active facts results in pure insertion (ADD)
|
|
157
|
+
fact_id = insert_semantic_fact(conn, scope_type, scope_id, new_fact)
|
|
158
|
+
results.append({
|
|
159
|
+
"fact": new_fact,
|
|
160
|
+
"action": "ADD",
|
|
161
|
+
"target_id": None,
|
|
162
|
+
"fact_id": fact_id
|
|
163
|
+
})
|
|
164
|
+
else:
|
|
165
|
+
# Pure bi-temporal logic: invalidates the old one and inserts the new consolidated statement
|
|
166
|
+
invalidate_semantic_fact(conn, t_id)
|
|
167
|
+
stmt_to_insert = (updated_statement or new_fact).strip()
|
|
168
|
+
fact_id = insert_semantic_fact(conn, scope_type, scope_id, stmt_to_insert)
|
|
169
|
+
results.append({
|
|
170
|
+
"fact": new_fact,
|
|
171
|
+
"action": "UPDATE",
|
|
172
|
+
"target_id": t_id,
|
|
173
|
+
"fact_id": fact_id,
|
|
174
|
+
"updated_statement": stmt_to_insert
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
elif action == "DELETE":
|
|
178
|
+
if t_id is None or t_id not in active_ids:
|
|
179
|
+
# No valid ID for invalidation, treat as NOOP
|
|
180
|
+
results.append({
|
|
181
|
+
"fact": new_fact,
|
|
182
|
+
"action": "NOOP",
|
|
183
|
+
"target_id": None,
|
|
184
|
+
"fact_id": None
|
|
185
|
+
})
|
|
186
|
+
else:
|
|
187
|
+
invalidate_semantic_fact(conn, t_id)
|
|
188
|
+
results.append({
|
|
189
|
+
"fact": new_fact,
|
|
190
|
+
"action": "DELETE",
|
|
191
|
+
"target_id": t_id,
|
|
192
|
+
"fact_id": None
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
else:
|
|
196
|
+
# In case of unknown or invalid action, treat as NOOP
|
|
197
|
+
results.append({
|
|
198
|
+
"fact": new_fact,
|
|
199
|
+
"action": "NOOP",
|
|
200
|
+
"target_id": None,
|
|
201
|
+
"fact_id": None
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
return results
|
|
205
|
+
|
|
206
|
+
def _extract_json_with_fallback(self, raw_response: str) -> Optional[dict[str, Any]]:
|
|
207
|
+
"""Attempts to extract the JSON dictionary from the LLM response with progressive fallback."""
|
|
208
|
+
if not raw_response or not raw_response.strip():
|
|
209
|
+
return None
|
|
210
|
+
text = raw_response.strip()
|
|
211
|
+
|
|
212
|
+
# Remove markdown delimiters if present
|
|
213
|
+
if text.startswith("```"):
|
|
214
|
+
lines = text.splitlines()
|
|
215
|
+
if lines[0].startswith("```"):
|
|
216
|
+
lines = lines[1:]
|
|
217
|
+
if lines and lines[-1].strip() == "```":
|
|
218
|
+
lines = lines[:-1]
|
|
219
|
+
text = "\n".join(lines).strip()
|
|
220
|
+
|
|
221
|
+
# Attempt 1: direct parsing
|
|
222
|
+
try:
|
|
223
|
+
return json.loads(text)
|
|
224
|
+
except (json.JSONDecodeError, ValueError):
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
# Attempt 2: regex to capture braces block
|
|
228
|
+
matches = _JSON_BLOCK_RE.findall(text)
|
|
229
|
+
for match in matches:
|
|
230
|
+
try:
|
|
231
|
+
return json.loads(match)
|
|
232
|
+
except (json.JSONDecodeError, ValueError):
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Attempt 3: simple capture of the first and last braces
|
|
236
|
+
first_brace = text.find("{")
|
|
237
|
+
last_brace = text.rfind("}")
|
|
238
|
+
if first_brace != -1 and last_brace > first_brace:
|
|
239
|
+
candidate = text[first_brace:last_brace + 1]
|
|
240
|
+
try:
|
|
241
|
+
return json.loads(candidate)
|
|
242
|
+
except (json.JSONDecodeError, ValueError):
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
return None
|