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.
core/middleware.py ADDED
@@ -0,0 +1,723 @@
1
+ """
2
+ core/middleware.py - Grafo Concierge v3.8.0 (Absolute Solidity)
3
+
4
+ The Central Facade - GrafoConcierge.
5
+
6
+ This is the class that the outside world consumes. It encapsulates ALL the
7
+ complexity of the internal layers (storage, ingestion, services, core)
8
+ into a clean, project-oriented public API.
9
+
10
+ Consumers of this class:
11
+ - interface/mcp_server.py (MCP Server → Claude Desktop, Cursor)
12
+ - interface/cli.py (Command Line Interface)
13
+ - interface/action_hooks.py (Operational Modules)
14
+ - Integration tests
15
+
16
+ Public methods:
17
+ - register_project() → Registers a new project in the graph
18
+ - wake_up() → Re-activates consciousness: compass + wings + commits
19
+ - mine() → Ingests files (concierge mine)
20
+ - hybrid_search() → Complete Hybrid Search v4
21
+ - commit_memory() → Registers consolidated changes
22
+ - get_resume() → Context Compass (concise summary)
23
+ - lazy_load() → On-demand node loading
24
+ - delete_project() → Cascaded project deletion
25
+ - find_similar() → Projects in the same wing
26
+ - status() → Project statistics
27
+
28
+ Principle: No internal class (SqliteStore, ChromaVectorStore, etc.)
29
+ is exposed to the outside world. Everything flows through this facade.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ import uuid as uuid_lib
36
+ from datetime import datetime
37
+ from typing import Any, Optional
38
+
39
+ from core.config import ConciergeConfig, DEFAULT_CONFIG
40
+ from core.project_index import ProjectIndex
41
+ from core.hybrid_search import HybridSearchEngine
42
+ from core.memory_extractor import SemanticExtractor
43
+ from storage.store import SqliteStore
44
+ from storage.vector_store import ChromaVectorStore, EmbeddingManager
45
+ from ingestion.orchestrator import IngestionManager
46
+
47
+ logger = logging.getLogger("grafo-concierge.middleware")
48
+
49
+
50
+ class GrafoConcierge:
51
+ """Central Facade of Grafo Concierge - unified public API.
52
+
53
+ By instantiating this class, all subsystems are initialized
54
+ automatically and interconnected.
55
+
56
+ Args:
57
+ sqlite_store: SqliteStore instance.
58
+ vector_store: ChromaVectorStore instance.
59
+ embedding_manager: EmbeddingManager instance.
60
+ ingestion_manager: IngestionManager instance.
61
+ config: Centralized parameters (default: DEFAULT_CONFIG).
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ sqlite_store: SqliteStore,
67
+ vector_store: ChromaVectorStore,
68
+ embedding_manager: EmbeddingManager,
69
+ ingestion_manager: IngestionManager,
70
+ config: ConciergeConfig = DEFAULT_CONFIG,
71
+ llm_adapter: Any = None,
72
+ ) -> None:
73
+ self._store = sqlite_store
74
+ self._vector = vector_store
75
+ self._embedder = embedding_manager
76
+ self._ingestion = ingestion_manager
77
+ self._config = config
78
+
79
+ # Core submodules
80
+ self._project_index = ProjectIndex(sqlite_store, config)
81
+ self._search_engine = HybridSearchEngine(
82
+ sqlite_store=sqlite_store,
83
+ vector_store=vector_store,
84
+ embedding_manager=embedding_manager,
85
+ project_index=self._project_index,
86
+ config=config,
87
+ )
88
+
89
+ # Semantic Extraction Engine (requires LLM adapter)
90
+ self._semantic_extractor: SemanticExtractor | None = (
91
+ SemanticExtractor(llm_adapter) if llm_adapter else None
92
+ )
93
+
94
+ logger.info("GrafoConcierge (Fachada) inicializada com sucesso.")
95
+
96
+ # ===================================================================
97
+ # REGISTER — Registers a new project
98
+ # ===================================================================
99
+
100
+ def register_project(
101
+ self,
102
+ folder_name: str,
103
+ wing: Optional[str] = None,
104
+ privacy_level: str = "PUBLIC",
105
+ summary: Optional[str] = None,
106
+ ) -> str:
107
+ """Registers a new project in the graph.
108
+
109
+ If the project already exists (by folder_name), returns the existing UUID.
110
+ Otherwise, generates a v4 UUID and creates the record.
111
+
112
+ Args:
113
+ folder_name: Source directory / project identifier.
114
+ wing: Primary Wing (if None, it will be automatically categorized
115
+ after the first ingestion).
116
+ privacy_level: Privacy level (PUBLIC, INTERNAL, RESTRICTED).
117
+ summary: Initial description of the project.
118
+
119
+ Returns:
120
+ UUID of the project (new or existing).
121
+ """
122
+ # Checks if already exists
123
+ try:
124
+ existing = self._store.get_project(folder_name)
125
+ logger.info("Projeto já existe: '%s' → %s", folder_name, existing["uuid"])
126
+ return existing["uuid"]
127
+ except Exception:
128
+ pass
129
+
130
+ project_uuid = str(uuid_lib.uuid4())
131
+ primary_wing = wing or self._config.default_wing
132
+
133
+ self._store.create_project(
134
+ uuid=project_uuid,
135
+ folder_name=folder_name,
136
+ primary_wing=primary_wing,
137
+ privacy_level=privacy_level,
138
+ summary=summary,
139
+ )
140
+
141
+ logger.info(
142
+ "Projeto registrado: '%s' → %s (wing='%s', privacy='%s')",
143
+ folder_name, project_uuid, primary_wing, privacy_level,
144
+ )
145
+ return project_uuid
146
+
147
+ # ===================================================================
148
+ # WAKE UP — Re-activation of consciousness
149
+ # ===================================================================
150
+
151
+ def wake_up(self, project_uuid: str) -> dict:
152
+ """Re-activates the agent's consciousness for a project.
153
+
154
+ Returns the minimum context package necessary for the agent
155
+ to resume work: Compass, Reference Wings, and last commits.
156
+
157
+ Aligned with the MCP Tool concierge_wakeup.
158
+
159
+ Args:
160
+ project_uuid: Project UUID.
161
+
162
+ Returns:
163
+ Dict containing:
164
+ {
165
+ "project": dict (project data),
166
+ "resume": str (Context Compass),
167
+ "reference_wings": list[str],
168
+ "recent_commits": list[dict],
169
+ "stats": dict,
170
+ }
171
+ """
172
+ project = self._store.get_project(project_uuid)
173
+ ref_wings = self._project_index.get_reference_wings(project_uuid)
174
+ recent_commits = self._store.get_recent_commits(project_uuid, limit=5)
175
+ stats = self._store.get_project_stats(project_uuid)
176
+
177
+ resume = project.get("summary", "")
178
+ if not resume:
179
+ resume = f"Projeto '{project.get('folder_name', 'unknown')}' — sem Bússola de Contexto definida."
180
+
181
+ result = {
182
+ "project": project,
183
+ "resume": resume,
184
+ "reference_wings": ref_wings,
185
+ "recent_commits": recent_commits,
186
+ "stats": stats,
187
+ }
188
+
189
+ logger.info(
190
+ "Wake-up: projeto=%s, commits=%d, ref_wings=%d",
191
+ project_uuid, len(recent_commits), len(ref_wings),
192
+ )
193
+ return result
194
+
195
+ # ===================================================================
196
+ # MINE — Ingestion of files
197
+ # ===================================================================
198
+
199
+ def mine(
200
+ self,
201
+ project_uuid: str,
202
+ source_path: str,
203
+ auto_tag: bool = True,
204
+ auto_categorize: bool = True,
205
+ ) -> dict:
206
+ """Runs the complete ingestion pipeline (concierge mine).
207
+
208
+ Delegates to IngestionManager and optionally re-categorizes
209
+ the project's Primary Wing after ingestion.
210
+
211
+ Args:
212
+ project_uuid: Project UUID.
213
+ source_path: Path to the source directory.
214
+ auto_tag: Enable automatic tag detection.
215
+ auto_categorize: Re-categorize wing after ingestion.
216
+
217
+ Returns:
218
+ Dict compatible with the MCP Tool concierge_mine response.
219
+ """
220
+ result = self._ingestion.mine(project_uuid, source_path, auto_tag)
221
+ result_dict = result.to_dict()
222
+
223
+ # Auto-categorization post-ingestion
224
+ if auto_categorize and result_dict.get("nodes_created", 0) > 0:
225
+ try:
226
+ wing = self._project_index.auto_categorize_project(project_uuid)
227
+ result_dict["auto_categorized_wing"] = wing
228
+ except Exception as e:
229
+ logger.warning("Auto-categorização falhou: %s", e)
230
+
231
+ return result_dict
232
+
233
+ # ===================================================================
234
+ # SEARCH — Hybrid Search v4
235
+ # ===================================================================
236
+
237
+ def hybrid_search(
238
+ self,
239
+ query: str,
240
+ project_uuid: str,
241
+ top_k: Optional[int] = None,
242
+ include_references: bool = False,
243
+ all_wings: bool = False,
244
+ node_type: Optional[str] = None,
245
+ ) -> list[dict]:
246
+ """Hybrid Search v4 — Complete Pipeline.
247
+
248
+ Delegates tri-signal orchestration to HybridSearchEngine.
249
+ Aligned with the MCP Tool concierge_search.
250
+
251
+ Args:
252
+ query: Search query.
253
+ project_uuid: Anchor project UUID.
254
+ top_k: Maximum results.
255
+ include_references: Include Reference Wings.
256
+ all_wings: Search in all wings.
257
+ node_type: Surgical filter by node type.
258
+
259
+ Returns:
260
+ List of dicts with final_score and breakdown, sorted DESC.
261
+ """
262
+ return self._search_engine.search(
263
+ query=query,
264
+ project_uuid=project_uuid,
265
+ top_k=top_k,
266
+ include_references=include_references,
267
+ all_wings=all_wings,
268
+ node_type=node_type,
269
+ )
270
+
271
+ # ===================================================================
272
+ # COMMIT — Consolidated changes registration
273
+ # ===================================================================
274
+
275
+ def commit_memory(
276
+ self,
277
+ project_uuid: str,
278
+ phase: str,
279
+ technical_changes: str,
280
+ updated_pointers: list[str],
281
+ node_ids: Optional[list[int]] = None,
282
+ ) -> int:
283
+ """Registers a memory commit in the graph.
284
+
285
+ Each commit saves technical changes and updated pointers.
286
+ If node_ids are provided, updates the last_commit_at of each node.
287
+
288
+ Aligned with the MCP Tool concierge_commit.
289
+
290
+ Args:
291
+ project_uuid: Project UUID.
292
+ phase: Current phase (planning, build, done, review).
293
+ technical_changes: Description of technical changes.
294
+ updated_pointers: List of updated pointers.
295
+ node_ids: IDs of affected nodes (to update recency).
296
+
297
+ Returns:
298
+ ID of the created commit.
299
+ """
300
+ commit_id = self._store.create_commit(
301
+ project_uuid=project_uuid,
302
+ phase=phase,
303
+ technical_changes=technical_changes,
304
+ updated_pointers=updated_pointers,
305
+ )
306
+
307
+ # Update recency of affected nodes
308
+ if node_ids:
309
+ for nid in node_ids:
310
+ try:
311
+ self._store.touch_node_commit(nid)
312
+ except Exception as e:
313
+ logger.warning("Falha ao tocar recência do nó %d: %s", nid, e)
314
+
315
+ logger.info(
316
+ "Commit registrado: id=%d, projeto=%s, fase='%s', nós_afetados=%d",
317
+ commit_id, project_uuid, phase, len(node_ids or []),
318
+ )
319
+ return commit_id
320
+
321
+ # ===================================================================
322
+ # RESUME — Context Compass
323
+ # ===================================================================
324
+
325
+ def get_resume(self, project_uuid: str) -> str:
326
+ """Returns the Context Compass (concise summary) of the project.
327
+
328
+ Aligned with the MCP Tool concierge_resume.
329
+
330
+ Args:
331
+ project_uuid: Project UUID.
332
+
333
+ Returns:
334
+ String with the project summary (max ~300 tokens).
335
+ """
336
+ project = self._store.get_project(project_uuid)
337
+ resume = project.get("summary", "")
338
+
339
+ if not resume:
340
+ stats = self._store.get_project_stats(project_uuid)
341
+ resume = (
342
+ f"Projeto '{project.get('folder_name', 'unknown')}' "
343
+ f"com {stats.get('total_nodes', 0)} nós e "
344
+ f"{stats.get('total_edges', 0)} arestas. "
345
+ f"Ala: {project.get('primary_wing', 'geral')}."
346
+ )
347
+
348
+ return resume
349
+
350
+ # ===================================================================
351
+ # LAZY LOAD — On-demand node loading
352
+ # ===================================================================
353
+
354
+ def lazy_load(self, node_id: int) -> dict:
355
+ """Loads complete node data under demand.
356
+
357
+ Updates the node's last_accessed to record the query.
358
+ Aligned with the MCP Tool concierge_load.
359
+
360
+ Args:
361
+ node_id: ID of the node to load.
362
+
363
+ Returns:
364
+ Dict with all node fields + outgoing edges.
365
+ """
366
+ node = self._store.get_node(node_id)
367
+
368
+ # Update last_accessed (relevant for Selective Amnesia)
369
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
370
+ self._store.update_node(node_id, last_accessed=now)
371
+
372
+ # Load outgoing edges for context
373
+ edges_out = self._store.get_edges_from(node_id)
374
+
375
+ result = {
376
+ **node,
377
+ "edges_out": edges_out,
378
+ }
379
+
380
+ logger.debug("Lazy Load: nó=%d, arestas=%d", node_id, len(edges_out))
381
+ return result
382
+
383
+ # ===================================================================
384
+ # DELETE — Project removal
385
+ # ===================================================================
386
+
387
+ def delete_project(self, project_uuid: str) -> None:
388
+ """Removes a project and all associated data.
389
+
390
+ Cascade: nodes, edges, trajectories, commits, and reference_wings.
391
+ Also clears associated vectors in ChromaDB.
392
+
393
+ Args:
394
+ project_uuid: UUID of the project to remove.
395
+ """
396
+ # Remove vectors from ChromaDB before SQLite (needs node_ids)
397
+ try:
398
+ nodes = self._store.get_nodes_by_project(project_uuid)
399
+ if nodes:
400
+ doc_ids = [f"node_{n['id']}" for n in nodes]
401
+ self._vector.delete_batch(doc_ids)
402
+ logger.info("Vetores removidos: %d embeddings do projeto %s", len(doc_ids), project_uuid)
403
+ except Exception as e:
404
+ logger.warning("Falha ao limpar vetores do projeto %s: %s", project_uuid, e)
405
+
406
+ # Remove from SQLite (CASCADE handles nodes, edges, etc.)
407
+ self._store.delete_project(project_uuid)
408
+ logger.info("Projeto removido: %s", project_uuid)
409
+
410
+ # ===================================================================
411
+ # SIMILAR — Projects in the same wing
412
+ # ===================================================================
413
+
414
+ def find_similar(
415
+ self,
416
+ project_uuid: str,
417
+ limit: int = 5,
418
+ include_references: bool = False,
419
+ all_wings: bool = False,
420
+ ) -> list[dict]:
421
+ """Searches similar projects by domain (same wing).
422
+
423
+ Args:
424
+ project_uuid: Anchor project UUID.
425
+ limit: Maximum results.
426
+ include_references: Include Reference Wings.
427
+ all_wings: All wings.
428
+
429
+ Returns:
430
+ List of dicts with data of similar projects.
431
+ """
432
+ return self._project_index.find_similar_projects(
433
+ project_uuid, limit, include_references, all_wings,
434
+ )
435
+
436
+ # ===================================================================
437
+ # STATUS — Project statistics
438
+ # ===================================================================
439
+
440
+ def status(self, project_uuid: str) -> dict:
441
+ """Returns complete project statistics.
442
+
443
+ Aligned with the MCP Tool concierge_status.
444
+
445
+ Args:
446
+ project_uuid: Project UUID.
447
+
448
+ Returns:
449
+ Dict with counters for nodes, edges, commits, trajectories, etc.
450
+ """
451
+ project = self._store.get_project(project_uuid)
452
+ stats = self._store.get_project_stats(project_uuid)
453
+ ref_wings = self._project_index.get_reference_wings(project_uuid)
454
+ last_phase = self._store.get_last_commit_phase(project_uuid)
455
+
456
+ return {
457
+ "project": project,
458
+ "stats": stats,
459
+ "reference_wings": ref_wings,
460
+ "last_commit_phase": last_phase,
461
+ }
462
+
463
+ # ===================================================================
464
+ # Submodule access (for advanced use / testing)
465
+ # ===================================================================
466
+
467
+ @property
468
+ def project_index(self) -> ProjectIndex:
469
+ """Access to ProjectIndex for advanced wing operations."""
470
+ return self._project_index
471
+
472
+ @property
473
+ def search_engine(self) -> HybridSearchEngine:
474
+ """Access to HybridSearchEngine for customized searches."""
475
+ return self._search_engine
476
+
477
+ @property
478
+ def store(self) -> SqliteStore:
479
+ """Access to SqliteStore (for advanced internal operations)."""
480
+ return self._store
481
+
482
+ def search_symbols(self, query: str, project_uuid: Optional[str] = None, limit: int = 50) -> list[dict]:
483
+ """Performs quick symbol search on FTS5."""
484
+ return self._store.search_symbols(query, project_uuid, limit)
485
+
486
+ def get_implementations(self, symbol_id: int) -> dict:
487
+ """Returns the exact AST code block stored in the node."""
488
+ node = self._store.get_node(symbol_id)
489
+ return {
490
+ "id": node["id"],
491
+ "label": node["label"],
492
+ "type": node["type"],
493
+ "project_uuid": node["project_uuid"],
494
+ "content": node.get("content"),
495
+ "file_hash": node.get("file_hash"),
496
+ }
497
+
498
+ def get_callers(self, symbol_id: int) -> list[dict]:
499
+ """Queries edges to return all calls to the symbol."""
500
+ return self._store.get_callers(symbol_id)
501
+
502
+ def get_full_topology(self, project_uuid: Optional[str] = None) -> dict[str, list[dict]]:
503
+ """Returns the complete topology (nodes and edges) in a lightweight format."""
504
+ return self._store.get_lightweight_topology(project_uuid)
505
+
506
+ # ===================================================================
507
+ # STORE FACT — Storing Semantic Facts via SemanticExtractor
508
+ # ===================================================================
509
+
510
+ def store_fact(
511
+ self,
512
+ scope_type: str,
513
+ scope_id: str,
514
+ fact_statement: str,
515
+ ) -> list[dict]:
516
+ """Stores a semantic fact in the graph via SemanticExtractor.
517
+
518
+ The SemanticExtractor evaluates the fact against existing facts
519
+ of the scope and decides: ADD, UPDATE, DELETE, or NOOP.
520
+
521
+ Aligned with the MCP Tool concierge_store_fact.
522
+
523
+ Args:
524
+ scope_type: Scope type ('user', 'session', 'agent', 'org').
525
+ scope_id: Unique identifier of the scope.
526
+ fact_statement: Text of the fact/preference to store.
527
+
528
+ Returns:
529
+ List of dicts detailing the decisions made.
530
+
531
+ Raises:
532
+ RuntimeError: If SemanticExtractor is not configured.
533
+ """
534
+ if self._semantic_extractor is None:
535
+ raise RuntimeError(
536
+ "SemanticExtractor not available: llm_adapter was not "
537
+ "provided during GrafoConcierge initialization."
538
+ )
539
+
540
+ def _do_store(conn) -> list[dict]:
541
+ return self._semantic_extractor.evaluate_and_store_facts(
542
+ conn=conn,
543
+ scope_type=scope_type,
544
+ scope_id=scope_id,
545
+ new_facts=[fact_statement],
546
+ )
547
+
548
+ results = self._store.write_callback(_do_store)
549
+ logger.info(
550
+ "store_fact: scope=%s/%s, results=%d",
551
+ scope_type, scope_id, len(results),
552
+ )
553
+
554
+ # Episodic vector synchronization if vector backend is QdrantVectorStore
555
+ try:
556
+ from core.vector_backend import QdrantVectorStore
557
+ if isinstance(self._vector, QdrantVectorStore) and results:
558
+ for fact in results:
559
+ fact_id = fact.get("id")
560
+ statement = fact.get("fact_statement")
561
+ if fact_id is not None and statement:
562
+ emb = self._embedder.embed(statement)
563
+ if emb:
564
+ metadata = {
565
+ "scope_type": scope_type,
566
+ "scope_id": scope_id,
567
+ "timestamp": datetime.utcnow().isoformat() + "Z",
568
+ "utility_alpha": 1.0,
569
+ "utility_beta": 1.0,
570
+ "fact_id": fact_id,
571
+ "fact_statement": statement
572
+ }
573
+ self._vector.store_embedding(
574
+ doc_id=f"fact_{fact_id}",
575
+ embedding=emb,
576
+ metadata=metadata
577
+ )
578
+ logger.info("Semantic fact %d synchronized in Qdrant (episodic_memory).", fact_id)
579
+ except Exception as q_err:
580
+ logger.warning("Failed to synchronize semantic fact in Qdrant: %s", q_err)
581
+
582
+ return results
583
+
584
+ # ===================================================================
585
+ # USER CORE MEMORY — Patch 1
586
+ # ===================================================================
587
+
588
+ def set_core_memory(
589
+ self,
590
+ scope_type: str,
591
+ scope_id: str,
592
+ block_label: str,
593
+ content: str,
594
+ ) -> int:
595
+ """Stores or updates a core memory block of the user/session.
596
+
597
+ Args:
598
+ scope_type: 'user', 'session', 'agent', or 'org'.
599
+ scope_id: Unique identifier of the scope.
600
+ block_label: Label of the memory block.
601
+ content: Content to store.
602
+
603
+ Returns:
604
+ ID of the inserted/updated record.
605
+ """
606
+ return self._store.set_core_memory(scope_type, scope_id, block_label, content)
607
+
608
+ def get_core_memory_blocks(
609
+ self,
610
+ scope_type: str,
611
+ scope_id: str,
612
+ block_label: Optional[str] = None,
613
+ ) -> list[dict]:
614
+ """Returns core memory blocks for a scope.
615
+
616
+ Args:
617
+ scope_type: Scope type.
618
+ scope_id: Unique identifier of the scope.
619
+ block_label: If provided, returns only the specific block
620
+ (list with 0 or 1 element). If absent, returns all.
621
+
622
+ Returns:
623
+ List of dicts with the user_core_memory records.
624
+ """
625
+ if block_label:
626
+ record = self._store.get_core_memory(scope_type, scope_id, block_label)
627
+ return [record] if record else []
628
+ return self._store.list_core_memory_blocks(scope_type, scope_id)
629
+
630
+ # ===================================================================
631
+ # BAYESIAN FEEDBACK LOOP — Patch 3
632
+ # ===================================================================
633
+
634
+ def update_fact_utility(self, fact_id: int, was_useful: bool) -> None:
635
+ """Updates the Bayesian utility of a semantic_fact.
636
+
637
+ Increments utility_alpha (success) or utility_beta (failure) of the fact,
638
+ feeding Thompson Sampling in HybridSearchEngine.
639
+
640
+ Args:
641
+ fact_id: ID of the semantic fact.
642
+ was_useful: True if the fact was useful, False otherwise.
643
+ """
644
+ from storage.semantic_logic import update_memory_utility
645
+
646
+ def _do_update(conn) -> None:
647
+ update_memory_utility(conn, fact_id, was_useful)
648
+
649
+ self._store.write_callback(_do_update)
650
+ logger.info(
651
+ "update_fact_utility: fact_id=%d, was_useful=%s → %s atualizado.",
652
+ fact_id, was_useful, "utility_alpha" if was_useful else "utility_beta",
653
+ )
654
+
655
+ # ===================================================================
656
+ # MCP ARSENAL — Backend-6.1: Life Cycle + Telemetry + Vector
657
+ # ===================================================================
658
+
659
+ def update_project(self, project_uuid: str, **fields: Any) -> None:
660
+ """Updates allowed fields of a project (registry).
661
+
662
+ Args:
663
+ project_uuid: Project UUID.
664
+ **fields: Fields to update (folder_name, primary_wing,
665
+ privacy_level, summary).
666
+ """
667
+ self._store.update_project(project_uuid, **fields)
668
+ logger.info("update_project: %s → campos=%s", project_uuid, list(fields.keys()))
669
+
670
+ def add_reference_wing(self, project_uuid: str, wing_name: str) -> None:
671
+ """Associates a Reference Wing to the project.
672
+
673
+ Args:
674
+ project_uuid: Project UUID.
675
+ wing_name: Wing name to associate.
676
+ """
677
+ self._store.add_reference_wing(project_uuid, wing_name)
678
+ logger.info("add_reference_wing: %s → wing=%s", project_uuid, wing_name)
679
+
680
+ def remove_reference_wing(self, project_uuid: str, wing_name: str) -> None:
681
+ """Removes a Reference Wing from the project.
682
+
683
+ Args:
684
+ project_uuid: Project UUID.
685
+ wing_name: Wing name to remove.
686
+ """
687
+ self._store.remove_reference_wing(project_uuid, wing_name)
688
+ logger.info("remove_reference_wing: %s → wing=%s", project_uuid, wing_name)
689
+
690
+ def get_trajectories(self, project_uuid: str) -> list[dict]:
691
+ """Retrieves the history of cognitive trajectories of the project.
692
+
693
+ Args:
694
+ project_uuid: Project UUID.
695
+
696
+ Returns:
697
+ List of dicts with the registered trajectories.
698
+ """
699
+ return self._store.get_trajectories(project_uuid)
700
+
701
+ def count_embeddings(self, project_uuid: Optional[str] = None) -> int:
702
+ """Returns the exact count of vectors in ChromaDB.
703
+
704
+ Args:
705
+ project_uuid: If provided, counts only for this project.
706
+
707
+ Returns:
708
+ Total number of stored embeddings.
709
+ """
710
+ return self._vector.count(project_uuid)
711
+
712
+ def reset_collection(self) -> bool:
713
+ """Destroys and recreates the vector collection (emergency repair).
714
+
715
+ CAUTION: Destructive and irreversible operation. Will require re-ingestion.
716
+
717
+ Returns:
718
+ True if successful, False otherwise.
719
+ """
720
+ result = self._vector.reset_collection()
721
+ if result:
722
+ logger.warning("reset_collection: coleção vetorial destruída e recriada.")
723
+ return result