From bc8e87814f8ec8a89f69a3ebb6ff95aa11502ba5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 04:11:41 +0000 Subject: [PATCH 1/4] fix: Review approvals for code-scan SCHEMA_DOC suggestions Customer saw "198 awaiting" with only 2 rows and "APPROVED 0 OF 2" because: 1) schema_documentation CHECK lacked CODE_DERIVED on self-host (Hibernate never rewrites CHECKs) so SCHEMA_DOC approve failed inside bulk-decide 2) Review list cache stayed stale after scans while the badge refetched totals 3) bulk success toast treated succeeded=0 as success with no failure details - Startup initializer keeps source CHECK aligned with DocumentationSource - Bulk decide returns failures[] with per-id errors; UI shows them as errors - Invalidate suggestions when a scan reaches COMPLETED/FAILED - Throw on malformed SCHEMA_DOC targets instead of silent skip - Seed + E2E scripts for review queue edge cases Co-authored-by: Venkat SF --- ...ntationSourceCompatibilityInitializer.java | 74 +++++ .../controller/CodeScanController.java | 18 +- .../service/codescan/CodeScanService.java | 42 ++- .../codescan/CodeSuggestionApplier.java | 13 +- .../codescan/CodeSuggestionApplierTest.java | 130 +++++++++ scripts/self-host/e2e-review-approvals.py | 273 ++++++++++++++++++ scripts/self-host/seed-review-suggestions.py | 172 +++++++++++ .../company-knowledge/CodeSourcesTab.jsx | 22 +- .../company-knowledge/SuggestionsQueueTab.jsx | 31 +- src/lib/hooks/queries/useCodeScan.js | 10 +- 10 files changed, 760 insertions(+), 25 deletions(-) create mode 100644 backend/src/main/java/com/dbaagent/config/SchemaDocumentationSourceCompatibilityInitializer.java create mode 100644 backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java create mode 100755 scripts/self-host/e2e-review-approvals.py create mode 100755 scripts/self-host/seed-review-suggestions.py diff --git a/backend/src/main/java/com/dbaagent/config/SchemaDocumentationSourceCompatibilityInitializer.java b/backend/src/main/java/com/dbaagent/config/SchemaDocumentationSourceCompatibilityInitializer.java new file mode 100644 index 0000000..2dfd635 --- /dev/null +++ b/backend/src/main/java/com/dbaagent/config/SchemaDocumentationSourceCompatibilityInitializer.java @@ -0,0 +1,74 @@ +package com.dbaagent.config; + +import com.dbaagent.model.DocumentationSource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.jdbc.core.JdbcTemplate; + +import javax.sql.DataSource; +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * Keeps {@code schema_documentation.source} CHECK aligned with + * {@link DocumentationSource}. + * + *

Hibernate {@code ddl-auto=update} does not rewrite CHECK constraints when an + * enum gains a value. Self-host installs that predate V90 therefore reject + * {@code CODE_DERIVED} rows written by code-scan suggestion approve — the Review + * queue shows {@code APPROVED 0 OF N} while every SCHEMA_DOC decide is swallowed + * by bulk-decide. Mirrors {@link BrainInitSchemaCompatibilityInitializer}. + */ +@Configuration +@Slf4j +public class SchemaDocumentationSourceCompatibilityInitializer { + + private static final String TABLE = "schema_documentation"; + private static final String COLUMN = "source"; + private static final String CONSTRAINT = "schema_documentation_source_check"; + + @Bean("schemaDocumentationSourceCompatibilityBootstrap") + @DependsOn("entityManagerFactory") + public Object schemaDocumentationSourceCompatibilityBootstrap(DataSource dataSource) { + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + if (!tableExists(jdbc, TABLE) || !columnExists(jdbc, TABLE, COLUMN)) { + return new Object(); + } + + String allowed = Arrays.stream(DocumentationSource.values()) + .map(DocumentationSource::name) + .map(v -> "'" + v + "'") + .collect(Collectors.joining(", ")); + + jdbc.execute("ALTER TABLE " + TABLE + " DROP CONSTRAINT IF EXISTS " + CONSTRAINT); + jdbc.execute( + "ALTER TABLE " + TABLE + + " ADD CONSTRAINT " + CONSTRAINT + + " CHECK ((" + COLUMN + ")::text = ANY (ARRAY[" + allowed + "]::text[]))" + ); + log.info("Ensured {} allows DocumentationSource values: {}", CONSTRAINT, allowed); + return new Object(); + } + + private boolean tableExists(JdbcTemplate jdbc, String tableName) { + Integer count = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = ? + """, Integer.class, tableName); + return count != null && count > 0; + } + + private boolean columnExists(JdbcTemplate jdbc, String tableName, String columnName) { + Integer count = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ? + AND column_name = ? + """, Integer.class, tableName, columnName); + return count != null && count > 0; + } +} diff --git a/backend/src/main/java/com/dbaagent/controller/CodeScanController.java b/backend/src/main/java/com/dbaagent/controller/CodeScanController.java index 93007fc..fb3198c 100644 --- a/backend/src/main/java/com/dbaagent/controller/CodeScanController.java +++ b/backend/src/main/java/com/dbaagent/controller/CodeScanController.java @@ -15,6 +15,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -156,16 +157,18 @@ public ResponseEntity> bulkDecide( if (body == null || body.ids() == null || body.ids().isEmpty()) { return ResponseEntity.badRequest().body(Map.of("error", "ids required")); } - var processed = codeScanService.bulkDecide( + var result = codeScanService.bulkDecide( body.ids(), body.decision(), accessControlService.getCurrentUsername(), body.note() ); - return ResponseEntity.ok(Map.of( - "requested", body.ids().size(), - "succeeded", processed.size() - )); + Map payload = new LinkedHashMap<>(); + payload.put("requested", body.ids().size()); + payload.put("succeeded", result.succeeded().size()); + payload.put("failed", result.failures().size()); + payload.put("failures", result.failures()); + return ResponseEntity.ok(payload); } private static CodeKnowledgeSuggestion.Status parseStatus(String s) { @@ -192,4 +195,9 @@ public ResponseEntity> handleIO(IOException e) { public ResponseEntity> handleBadInput(IllegalArgumentException e) { return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalState(IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } } diff --git a/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java b/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java index a0fe2d0..e9e0513 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java @@ -579,24 +579,46 @@ public CodeKnowledgeSuggestion decide(String suggestionId, * transaction inside applier.approve/reject, so one bad row cannot mark a * shared transaction rollback-only and break every subsequent item. */ - public List bulkDecide(List ids, - String decision, - String decidedBy, - String note) { + public BulkDecideResult bulkDecide(List ids, + String decision, + String decidedBy, + String note) { List out = new ArrayList<>(); - int failures = 0; + List> failures = new ArrayList<>(); for (String id : ids) { try { out.add(decide(id, decision, decidedBy, note)); } catch (Exception e) { - failures++; - log.warn("bulk decide skipped {}: {}", id, e.getMessage()); + String message = rootMessage(e); + failures.add(Map.of("id", id, "error", message)); + log.warn("bulk decide skipped {}: {}", id, message); } } - if (failures > 0) { - log.info("bulk decide: {} succeeded, {} failed", out.size(), failures); + if (!failures.isEmpty()) { + log.info("bulk decide: {} succeeded, {} failed", out.size(), failures.size()); } - return out; + return new BulkDecideResult(out, failures); + } + + public record BulkDecideResult( + List succeeded, + List> failures + ) {} + + private static String rootMessage(Throwable e) { + Throwable cur = e; + String best = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + while (cur != null) { + if (cur.getMessage() != null && !cur.getMessage().isBlank()) { + best = cur.getMessage(); + } + cur = cur.getCause(); + } + // Keep API payloads short — full stack stays in logs. + if (best.length() > 400) { + return best.substring(0, 397) + "..."; + } + return best; } // ---- Helpers ---- diff --git a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java index 77f7837..174bf64 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java @@ -82,15 +82,20 @@ private void applySchemaDoc(CodeKnowledgeSuggestion suggestion, String decidedBy String objectName; String parentObject = null; String target = suggestion.getTargetObject(); + // Never silently skip — bulk-decide would report succeeded while nothing + // was written, and the Review UI would look like a no-op approval. if (target == null || target.isBlank()) { - log.warn("SCHEMA_DOC suggestion {} has no targetObject; skipping", suggestion.getId()); - return; + throw new IllegalArgumentException( + "SCHEMA_DOC suggestion " + suggestion.getId() + " has no targetObject" + ); } if (objectType == SchemaDocumentation.DocumentationType.COLUMN) { int dot = target.indexOf('.'); if (dot <= 0 || dot >= target.length() - 1) { - log.warn("SCHEMA_DOC column suggestion {} has malformed target '{}'", suggestion.getId(), target); - return; + throw new IllegalArgumentException( + "SCHEMA_DOC column suggestion " + suggestion.getId() + + " has malformed target '" + target + "'" + ); } parentObject = target.substring(0, dot); objectName = target.substring(dot + 1); diff --git a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java new file mode 100644 index 0000000..773806f --- /dev/null +++ b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java @@ -0,0 +1,130 @@ +package com.dbaagent.service.codescan; + +import com.dbaagent.model.DocumentationSource; +import com.dbaagent.model.SchemaDocumentation; +import com.dbaagent.model.SchemaMetadata; +import com.dbaagent.model.code.CodeKnowledgeSuggestion; +import com.dbaagent.repository.CodeKnowledgeSuggestionRepository; +import com.dbaagent.repository.SchemaDocumentationRepository; +import com.dbaagent.service.CompanyKnowledgeService; +import com.dbaagent.service.SchemaScannerService; +import com.dbaagent.service.TrainingService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CodeSuggestionApplierTest { + + @Mock private CodeKnowledgeSuggestionRepository suggestionRepository; + @Mock private SchemaDocumentationRepository schemaDocRepository; + @Mock private CompanyKnowledgeService companyKnowledgeService; + @Mock private TrainingService trainingService; + @Mock private SchemaScannerService schemaScannerService; + + private CodeSuggestionApplier applier; + + @BeforeEach + void setUp() { + applier = new CodeSuggestionApplier( + suggestionRepository, + schemaDocRepository, + companyKnowledgeService, + trainingService, + schemaScannerService + ); + } + + @Test + void approveSchemaDocColumn_writesCodeDerivedRow() throws Exception { + CodeKnowledgeSuggestion suggestion = baseSuggestion(); + suggestion.setTargetKind(CodeKnowledgeSuggestion.TargetKind.SCHEMA_DOC); + suggestion.setTargetObject("fct_ashram_visit.party_size"); + suggestion.setPayload(Map.of("objectKind", "COLUMN", "businessTerms", List.of("party size"))); + + when(suggestionRepository.findById("s1")).thenReturn(Optional.of(suggestion)); + when(suggestionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(schemaDocRepository.findByConnectionIdAndObjectTypeAndObjectNameAndParentObjectAndSource( + eq("conn-1"), + eq(SchemaDocumentation.DocumentationType.COLUMN), + eq("party_size"), + eq("acme_erp.fct_ashram_visit"), + eq(DocumentationSource.CODE_DERIVED) + )).thenReturn(Optional.empty()); + SchemaMetadata schema = new SchemaMetadata(); + schema.setDatabaseName("acme_erp"); + when(schemaScannerService.scanSchema("conn-1")).thenReturn(schema); + when(schemaDocRepository.save(any())).thenAnswer(inv -> { + SchemaDocumentation doc = inv.getArgument(0); + doc.setId("doc-1"); + return doc; + }); + + CodeKnowledgeSuggestion approved = applier.approve("s1", "admin", null); + + assertThat(approved.getStatus()).isEqualTo(CodeKnowledgeSuggestion.Status.APPROVED); + assertThat(approved.getAppliedDocId()).isEqualTo("doc-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SchemaDocumentation.class); + verify(schemaDocRepository).save(captor.capture()); + SchemaDocumentation saved = captor.getValue(); + assertThat(saved.getSource()).isEqualTo(DocumentationSource.CODE_DERIVED); + assertThat(saved.getObjectName()).isEqualTo("party_size"); + assertThat(saved.getParentObject()).isEqualTo("acme_erp.fct_ashram_visit"); + assertThat(saved.getBusinessTerms()).isEqualTo("party size"); + verify(trainingService).upsertDocumentationEmbedding(saved); + } + + @Test + void approveSchemaDoc_blankTarget_throwsInsteadOfSilentSkip() { + CodeKnowledgeSuggestion suggestion = baseSuggestion(); + suggestion.setTargetKind(CodeKnowledgeSuggestion.TargetKind.SCHEMA_DOC); + suggestion.setTargetObject(" "); + when(suggestionRepository.findById("s1")).thenReturn(Optional.of(suggestion)); + + assertThatThrownBy(() -> applier.approve("s1", "admin", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no targetObject"); + verify(schemaDocRepository, never()).save(any()); + verify(suggestionRepository, never()).save(any()); + } + + @Test + void approveRejectedSuggestion_isRejected() { + CodeKnowledgeSuggestion suggestion = baseSuggestion(); + suggestion.setStatus(CodeKnowledgeSuggestion.Status.REJECTED); + when(suggestionRepository.findById("s1")).thenReturn(Optional.of(suggestion)); + + assertThatThrownBy(() -> applier.approve("s1", "admin", null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot be approved"); + } + + private static CodeKnowledgeSuggestion baseSuggestion() { + return CodeKnowledgeSuggestion.builder() + .id("s1") + .jobId("job-1") + .connectionId("conn-1") + .title("Visit party size") + .content("Number of people in the visiting party.") + .confidence(0.99) + .status(CodeKnowledgeSuggestion.Status.PENDING) + .sourceFiles(List.of(Map.of("path", "src/Seed.java", "startLine", 1, "endLine", 10))) + .build(); + } +} diff --git a/scripts/self-host/e2e-review-approvals.py b/scripts/self-host/e2e-review-approvals.py new file mode 100755 index 0000000..1a792ed --- /dev/null +++ b/scripts/self-host/e2e-review-approvals.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""E2E edge-case suite for Company Knowledge Review approvals. + +Requires a running stack + seeded PENDING suggestions: + python3 scripts/self-host/seed-review-suggestions.py --count 20 + python3 scripts/self-host/e2e-review-approvals.py [connectionId] +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import urllib.error +from http.cookiejar import CookieJar +from pathlib import Path +from urllib.request import HTTPCookieProcessor, Request, build_opener + +ROOT = Path(__file__).resolve().parents[2] +ENV = ROOT / ".env" + + +def load_env(path: Path) -> dict[str, str]: + out: dict[str, str] = {} + if not path.exists(): + return out + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip().strip('"').strip("'") + return out + + +def main() -> int: + env = {**load_env(ENV), **os.environ} + email = env.get("DEEPSQL_INITIAL_ADMIN_EMAIL") or env.get("DEEPSQL_SMOKE_EMAIL") + password = env.get("DEEPSQL_INITIAL_ADMIN_PASSWORD") or env.get("DEEPSQL_SMOKE_PASSWORD") + if not email or not password: + print("Missing admin credentials", file=sys.stderr) + return 1 + + backend = f"http://localhost:{env.get('DEEPSQL_BACKEND_PORT', '8080')}/api" + opener = build_opener(HTTPCookieProcessor(CookieJar())) + failures: list[str] = [] + + def req(url: str, data=None, method: str | None = None): + body = None + headers: dict[str, str] = {} + if data is not None: + body = json.dumps(data).encode() + headers["Content-Type"] = "application/json" + m = method or ("POST" if data is not None else "GET") + r = Request(url, data=body, headers=headers, method=m) + with opener.open(r, timeout=180) as resp: + return json.loads(resp.read().decode() or "null") + + def check(name: str, ok: bool, detail: str = ""): + status = "PASS" if ok else "FAIL" + print(f"[{status}] {name}{(': ' + detail) if detail else ''}") + if not ok: + failures.append(name) + + print("→ login") + req(f"{backend}/auth/login", {"email": email, "password": password}) + + conn = sys.argv[1] if len(sys.argv) > 1 else None + if not conn: + conns = req(f"{backend}/connections") + items = conns if isinstance(conns, list) else (conns.get("connections") or []) + conn = (items[0].get("connectionId") or items[0].get("id")) if items else None + if not conn: + print("No connection", file=sys.stderr) + return 1 + print(f"→ connection {conn}") + + # ── 1. Count consistency: badge probe vs full page ───────────────────── + probe = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=1") + page = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=200") + total = probe.get("totalElements") + content_len = len(page.get("content") or []) + check( + "pending count consistency", + total == content_len or (total > 200 and content_len == 200), + f"totalElements={total} content={content_len}", + ) + + pending = [s for s in (page.get("content") or []) if s.get("status") == "PENDING"] + schema_docs = [s for s in pending if s.get("targetKind") == "SCHEMA_DOC"] + knowledge = [s for s in pending if s.get("targetKind") == "KNOWLEDGE_ENTRY"] + check("has SCHEMA_DOC pending", len(schema_docs) >= 1, f"n={len(schema_docs)}") + check("has KNOWLEDGE_ENTRY pending", len(knowledge) >= 1, f"n={len(knowledge)}") + + # ── 2. Constraint bootstrap: CODE_DERIVED must be allowed ────────────── + constraint = subprocess.check_output( + [ + "sudo", "-u", "postgres", "psql", "-d", "dba_agent", "-At", "-c", + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conname='schema_documentation_source_check'", + ], + text=True, + ).strip() + check("CODE_DERIVED in source CHECK", "CODE_DERIVED" in constraint, constraint) + + # ── 3. Approve SCHEMA_DOC (the customer failure path) ────────────────── + target = schema_docs[0] + approved = req( + f"{backend}/code-scan/suggestions/{target['id']}/decide?connectionId={conn}", + {"decision": "APPROVED"}, + ) + check( + "approve SCHEMA_DOC", + approved.get("status") == "APPROVED" and bool(approved.get("appliedDocId")), + f"status={approved.get('status')} appliedDocId={approved.get('appliedDocId')}", + ) + + # ── 4. Idempotent re-approve ─────────────────────────────────────────── + again = req( + f"{backend}/code-scan/suggestions/{target['id']}/decide?connectionId={conn}", + {"decision": "APPROVED"}, + ) + check("re-approve is idempotent", again.get("status") == "APPROVED") + + # ── 5. Reject cannot follow approve ──────────────────────────────────── + try: + req( + f"{backend}/code-scan/suggestions/{target['id']}/decide?connectionId={conn}", + {"decision": "REJECTED"}, + ) + check("reject after approve blocked", False, "expected 400") + except urllib.error.HTTPError as e: + check("reject after approve blocked", e.code == 400, f"http={e.code}") + + # ── 6. Reject a fresh pending item ───────────────────────────────────── + page2 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=50") + pending2 = page2.get("content") or [] + to_reject = next((s for s in pending2 if s.get("targetKind") == "SCHEMA_DOC"), None) + if to_reject: + rejected = req( + f"{backend}/code-scan/suggestions/{to_reject['id']}/decide?connectionId={conn}", + {"decision": "REJECTED", "note": "not useful"}, + ) + check("reject SCHEMA_DOC", rejected.get("status") == "REJECTED") + else: + check("reject SCHEMA_DOC", False, "no pending SCHEMA_DOC left") + + # ── 7. Bulk approve mix (schema + knowledge) reports per-id failures ─── + page3 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=50") + mix = [] + for kind in ("SCHEMA_DOC", "KNOWLEDGE_ENTRY"): + hit = next((s for s in (page3.get("content") or []) if s.get("targetKind") == kind), None) + if hit: + mix.append(hit["id"]) + if len(mix) >= 2: + bulk = req( + f"{backend}/code-scan/suggestions/bulk-decide?connectionId={conn}", + {"ids": mix, "decision": "APPROVED"}, + ) + check( + "bulk approve reports counts", + bulk.get("requested") == len(mix) + and bulk.get("succeeded") == len(mix) + and bulk.get("failed") == 0 + and isinstance(bulk.get("failures"), list), + json.dumps(bulk), + ) + else: + check("bulk approve reports counts", False, f"need 2 pending kinds, got {len(mix)}") + + # ── 8. Bulk with unknown id surfaces failure entry ───────────────────── + page4 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=1") + one = (page4.get("content") or [None])[0] + if one: + bulk_bad = req( + f"{backend}/code-scan/suggestions/bulk-decide?connectionId={conn}", + {"ids": [one["id"], "00000000-0000-0000-0000-000000000000"], "decision": "APPROVED"}, + ) + check( + "bulk partial failure surfaces failures[]", + bulk_bad.get("succeeded") == 1 + and bulk_bad.get("failed") == 1 + and len(bulk_bad.get("failures") or []) == 1, + json.dumps(bulk_bad), + ) + else: + check("bulk partial failure surfaces failures[]", False, "no pending left") + + # ── 9. Knowledge entry approve lands in company knowledge ────────────── + page5 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=APPROVED&page=0&size=50") + knowledge_ok = [ + s for s in (page5.get("content") or []) + if s.get("targetKind") == "KNOWLEDGE_ENTRY" and s.get("appliedEntryId") + ] + check("knowledge approvals applied", len(knowledge_ok) >= 1, f"n={len(knowledge_ok)}") + + # ── 10. Simulate missing CODE_DERIVED then ensure repair path works ──── + # Existing CODE_DERIVED rows block shrinking the CHECK, so park them first. + subprocess.check_call([ + "sudo", "-u", "postgres", "psql", "-d", "dba_agent", "-c", + "UPDATE schema_documentation SET source='USER' WHERE source='CODE_DERIVED'; " + "ALTER TABLE schema_documentation DROP CONSTRAINT IF EXISTS schema_documentation_source_check; " + "ALTER TABLE schema_documentation ADD CONSTRAINT schema_documentation_source_check " + "CHECK (source::text = ANY (ARRAY['USER','AI_GENERATED','CSV_IMPORT']::text[]));", + ]) + broken = subprocess.check_output( + [ + "sudo", "-u", "postgres", "psql", "-d", "dba_agent", "-At", "-c", + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conname='schema_documentation_source_check'", + ], + text=True, + ).strip() + check("can break CHECK for simulation", "CODE_DERIVED" not in broken) + + page6 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=20") + schema_pending = next( + (s for s in (page6.get("content") or []) if s.get("targetKind") == "SCHEMA_DOC"), + None, + ) + if schema_pending: + bulk_broken = req( + f"{backend}/code-scan/suggestions/bulk-decide?connectionId={conn}", + {"ids": [schema_pending["id"]], "decision": "APPROVED"}, + ) + check( + "broken CHECK yields failed bulk with error detail", + bulk_broken.get("succeeded") == 0 + and bulk_broken.get("failed") == 1 + and "schema_documentation_source_check" in json.dumps(bulk_broken.get("failures")), + json.dumps(bulk_broken), + ) + else: + check("broken CHECK yields failed bulk with error detail", False, "no pending SCHEMA_DOC") + + # Repair CHECK the same way the startup initializer does (without restart). + subprocess.check_call([ + "sudo", "-u", "postgres", "psql", "-d", "dba_agent", "-c", + "ALTER TABLE schema_documentation DROP CONSTRAINT IF EXISTS schema_documentation_source_check; " + "ALTER TABLE schema_documentation ADD CONSTRAINT schema_documentation_source_check " + "CHECK (source::text = ANY (ARRAY['USER','AI_GENERATED','CSV_IMPORT','CODE_DERIVED']::text[]));", + ]) + repaired = subprocess.check_output( + [ + "sudo", "-u", "postgres", "psql", "-d", "dba_agent", "-At", "-c", + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conname='schema_documentation_source_check'", + ], + text=True, + ).strip() + check("CHECK repaired with CODE_DERIVED", "CODE_DERIVED" in repaired) + + if schema_pending: + retry = req( + f"{backend}/code-scan/suggestions/{schema_pending['id']}/decide?connectionId={conn}", + {"decision": "APPROVED"}, + ) + check( + "approve SCHEMA_DOC after CHECK repair", + retry.get("status") == "APPROVED" and bool(retry.get("appliedDocId")), + f"status={retry.get('status')}", + ) + + print() + if failures: + print(f"✗ {len(failures)} failure(s): {failures}", file=sys.stderr) + return 1 + print("✓ All review-approval edge cases passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/self-host/seed-review-suggestions.py b/scripts/self-host/seed-review-suggestions.py new file mode 100755 index 0000000..dd302c5 --- /dev/null +++ b/scripts/self-host/seed-review-suggestions.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Seed code-scan review suggestions for E2E testing (no LLM required). + +Creates a code_scan_source + completed job + N PENDING suggestions +(SCHEMA_DOC + KNOWLEDGE_ENTRY) against an existing DeepSQL connection. + +Usage (repo root, backend running with auth): + python3 scripts/self-host/seed-review-suggestions.py [connectionId] [--count 50] +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import uuid +from datetime import datetime, timezone +from http.cookiejar import CookieJar +from pathlib import Path +from urllib.request import HTTPCookieProcessor, Request, build_opener +import urllib.error + +ROOT = Path(__file__).resolve().parents[2] +ENV = ROOT / ".env" + + +def load_env(path: Path) -> dict[str, str]: + out: dict[str, str] = {} + if not path.exists(): + return out + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip().strip('"').strip("'") + return out + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("connection_id", nargs="?") + parser.add_argument("--count", type=int, default=50) + parser.add_argument("--name", default="E2E seeded app code") + args = parser.parse_args() + + env = {**load_env(ENV), **os.environ} + email = env.get("DEEPSQL_INITIAL_ADMIN_EMAIL") or env.get("DEEPSQL_SMOKE_EMAIL") + password = env.get("DEEPSQL_INITIAL_ADMIN_PASSWORD") or env.get("DEEPSQL_SMOKE_PASSWORD") + if not email or not password: + print("Missing admin credentials in .env", file=sys.stderr) + return 1 + + frontend = f"http://localhost:{env.get('DEEPSQL_FRONTEND_PORT', '3000')}" + backend = f"http://localhost:{env.get('DEEPSQL_BACKEND_PORT', '8080')}/api" + opener = build_opener(HTTPCookieProcessor(CookieJar())) + + def req(url: str, data=None, method: str | None = None): + body = None + headers: dict[str, str] = {} + if data is not None: + body = json.dumps(data).encode() + headers["Content-Type"] = "application/json" + m = method or ("POST" if data is not None else "GET") + r = Request(url, data=body, headers=headers, method=m) + with opener.open(r, timeout=60) as resp: + raw = resp.read().decode() or "null" + return json.loads(raw) + + print("→ login") + try: + req(f"{frontend}/api/auth/login", {"email": email, "password": password}) + except Exception: + req(f"{backend}/auth/login", {"email": email, "password": password}) + + conn_id = args.connection_id + if not conn_id: + conns = req(f"{backend}/connections") + items = conns if isinstance(conns, list) else (conns.get("connections") or []) + for c in items: + name = (c.get("connectionName") or "").lower() + if "acme" in name or "multi" in name: + conn_id = c.get("connectionId") or c.get("id") + break + if not conn_id and items: + conn_id = items[0].get("connectionId") or items[0].get("id") + if not conn_id: + print("FAIL: no connectionId", file=sys.stderr) + return 1 + print(f"→ connection {conn_id}") + + # Prefer SQL seed via vault DB for speed/reliability (no archive upload). + # Falls back to printing SQL if psql unavailable. + source_id = str(uuid.uuid4()) + job_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(sep=" ", timespec="seconds") + + suggestions_sql = [] + # Mix of SCHEMA_DOC (columns + tables) and KNOWLEDGE_ENTRY to cover both apply paths. + fixtures = [ + ("SCHEMA_DOC", "rpt_campaign_performance.refund_rate_pct", "Refund rate percentage", + "Percentage of refunded revenue for the campaign.", {"objectKind": "COLUMN", "businessTerms": ["refund rate"]}), + ("SCHEMA_DOC", "fct_ashram_visit.party_size", "Visit party size", + "Number of people in the visiting party.", {"objectKind": "COLUMN", "businessTerms": ["party size"]}), + ("SCHEMA_DOC", "crm.customers", "CRM customers table", + "Master customer records for CRM.", {"objectKind": "TABLE", "businessTerms": ["customer"]}), + ("KNOWLEDGE_ENTRY", None, "Refunds exclude gift cards", + "Business rule: refund_rate_pct must exclude gift-card redemptions.", + {"entryType": "BUSINESS_RULE"}), + ] + # Pad to requested count by cloning column docs with unique suffixes. + while len(fixtures) < args.count: + i = len(fixtures) + fixtures.append(( + "SCHEMA_DOC", + f"seed_table_{i // 10}.col_{i}", + f"Seeded column {i}", + f"Auto-seeded documentation for col_{i}.", + {"objectKind": "COLUMN", "businessTerms": [f"term_{i}"]}, + )) + + for kind, target, title, content, payload in fixtures[: args.count]: + sid = str(uuid.uuid4()) + conf = 0.99 if "refund" in (title or "").lower() or "party" in (title or "").lower() else 0.75 + linked_tables = json.dumps([target.split(".")[0]] if target and "." in target else ([target] if target else [])) + linked_cols = json.dumps([target] if target and "." in target else []) + source_files = json.dumps([{"path": "src/models/Seed.java", "startLine": 10, "endLine": 40, "rationale": "seed"}]) + payload_json = json.dumps(payload).replace("'", "''") + target_sql = "NULL" if not target else f"'{target}'" + suggestions_sql.append( + f"""INSERT INTO code_knowledge_suggestion + (id, job_id, connection_id, target_kind, target_object, title, content, payload, + linked_tables, linked_columns, source_files, confidence, status, created_at) + VALUES + ('{sid}', '{job_id}', '{conn_id}', '{kind}', {target_sql}, + '{title.replace("'", "''")}', '{content.replace("'", "''")}', + '{payload_json}'::jsonb, '{linked_tables}'::jsonb, '{linked_cols}'::jsonb, + '{source_files}'::jsonb, {conf}, 'PENDING', TIMESTAMP '{now}');""" + ) + + sql = f""" +INSERT INTO code_scan_source (id, connection_id, name, kind, archive_sha256, total_bytes, file_count, active, created_by, created_at) +VALUES ('{source_id}', '{conn_id}', '{args.name}', 'UPLOAD', 'seed', 1024, 12, TRUE, 'seed', TIMESTAMP '{now}') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO code_scan_job +(id, source_id, connection_id, status, progress, current_step, files_total, files_parsed, chunks_sent, + suggestions_emitted, started_at, completed_at, message, triggered_by, created_at) +VALUES +('{job_id}', '{source_id}', '{conn_id}', 'COMPLETED', 100, 'done', 12, 12, 12, + {len(fixtures[:args.count])}, TIMESTAMP '{now}', TIMESTAMP '{now}', 'seeded', 'seed', TIMESTAMP '{now}'); + +{chr(10).join(suggestions_sql)} +""" + sql_path = Path("/tmp/seed-review-suggestions.sql") + sql_path.write_text(sql) + print(f"→ writing {sql_path} ({args.count} suggestions)") + rc = os.system(f"sudo -u postgres psql -d dba_agent -v ON_ERROR_STOP=1 -f {sql_path} >/tmp/seed-review.out 2>&1") + if rc != 0: + print(open("/tmp/seed-review.out").read(), file=sys.stderr) + return 1 + + page = req(f"{backend}/code-scan/suggestions?connectionId={conn_id}&status=PENDING&page=0&size=1") + total = page.get("totalElements") + print(f"→ PENDING totalElements={total}") + print(f"✓ Seeded source={source_id} job={job_id} suggestions={args.count}") + print(conn_id) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/components/company-knowledge/CodeSourcesTab.jsx b/src/components/company-knowledge/CodeSourcesTab.jsx index 55e45f7..4a1114a 100644 --- a/src/components/company-knowledge/CodeSourcesTab.jsx +++ b/src/components/company-knowledge/CodeSourcesTab.jsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { Edit3, Loader2, RefreshCw, Save, Trash2, Upload, X } from 'lucide-react' import { codeScanAPI } from '@/lib/api/client' import { queryKeys } from '@/lib/queryKeys' @@ -42,6 +42,8 @@ function SourceCard({ source, connectionId, onDelete, externalActiveJobId }) { const inputRef = useRef(null) const [editingFocus, setEditingFocus] = useState(false) const [focusDraft, setFocusDraft] = useState(source.focusText || '') + const queryClient = useQueryClient() + const lastTerminalJobRef = useRef(null) // Pull the most-recent job for this source so we can show progress even // after a page reload, and detect any running job that wasn't started in @@ -69,6 +71,24 @@ function SourceCard({ source, connectionId, onDelete, externalActiveJobId }) { // even when the user lands on the page after a scan was already started. const effective = stream || latestJob + // When a scan finishes, refresh the Review queue. Previously only scan + // *start* invalidated queries, so the badge (fresh totalElements) could + // show 198 while the list still served a stale 2-item cache. + useEffect(() => { + if (!connectionId || !effective?.id || !effective?.status) return + if (!['COMPLETED', 'FAILED', 'CANCELLED'].includes(effective.status)) return + const key = `${effective.id}:${effective.status}:${effective.suggestionsEmitted ?? 0}` + if (lastTerminalJobRef.current === key) return + lastTerminalJobRef.current = key + queryClient.invalidateQueries({ queryKey: queryKeys.codeScan.all(connectionId) }) + }, [ + connectionId, + effective?.id, + effective?.status, + effective?.suggestionsEmitted, + queryClient, + ]) + const handleRescan = async (file) => { setError(null) try { diff --git a/src/components/company-knowledge/SuggestionsQueueTab.jsx b/src/components/company-knowledge/SuggestionsQueueTab.jsx index 1a41e0f..a15ef6d 100644 --- a/src/components/company-knowledge/SuggestionsQueueTab.jsx +++ b/src/components/company-knowledge/SuggestionsQueueTab.jsx @@ -254,11 +254,28 @@ export default function SuggestionsQueueTab({ connectionId }) { { connectionId, ids: selectedIds, decision }, { onSuccess: (data) => { - const succeeded = data?.succeeded ?? selectedIds.length + const succeeded = data?.succeeded ?? 0 const requested = data?.requested ?? selectedIds.length - setBulkSuccess( - `${decision === 'APPROVED' ? 'Approved' : 'Rejected'} ${succeeded} of ${requested}`, - ) + const failed = data?.failed ?? Math.max(0, requested - succeeded) + const verb = decision === 'APPROVED' ? 'Approved' : 'Rejected' + const summary = `${verb} ${succeeded} of ${requested}` + if (failed > 0 || succeeded === 0) { + const details = Array.isArray(data?.failures) + ? data.failures + .slice(0, 3) + .map((f) => f?.error || f?.id) + .filter(Boolean) + .join(' · ') + : '' + setBulkError( + details + ? `${summary}. ${failed} failed: ${details}` + : `${summary}. ${failed} failed — check server logs for details.`, + ) + setBulkSuccess(null) + } else { + setBulkSuccess(summary) + } setSelected(new Set()) }, onError: (err) => { @@ -279,6 +296,12 @@ export default function SuggestionsQueueTab({ connectionId }) { return next }) if (pinnedId === id) setPinnedId(null) + setBulkError(null) + setBulkSuccess(decision === 'APPROVED' ? 'Approved 1 suggestion' : 'Rejected 1 suggestion') + }, + onError: (err) => { + setBulkSuccess(null) + setBulkError(err?.response?.data?.error || err?.message || 'Decision failed') }, }, ) diff --git a/src/lib/hooks/queries/useCodeScan.js b/src/lib/hooks/queries/useCodeScan.js index 6645b7e..18ffbf2 100644 --- a/src/lib/hooks/queries/useCodeScan.js +++ b/src/lib/hooks/queries/useCodeScan.js @@ -122,6 +122,7 @@ export function useAllCodeScanSuggestions({ connectionId, status = 'PENDING' }) const pageSize = 200 // Cap to keep things bounded; one project's worst-case so far is ~750. const maxPages = 50 + let totalElements = null while (page < maxPages) { const data = await codeScanAPI.listSuggestions({ connectionId, @@ -130,14 +131,21 @@ export function useAllCodeScanSuggestions({ connectionId, status = 'PENDING' }) size: pageSize, }) const content = data?.content || [] + if (typeof data?.totalElements === 'number') { + totalElements = data.totalElements + } out.push(...content) + // Prefer server total when present so a truncated first page cannot + // silently stop early while the Review badge still shows 198. + if (totalElements != null && out.length >= totalElements) break if (content.length < pageSize) break page += 1 } return out }, enabled: Boolean(connectionId), - staleTime: 30_000, + // Badge probe and list must stay in sync after scans complete. + staleTime: 0, }) } From a80b4cb8f6873990475ae58106b283016288b3ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 04:11:54 +0000 Subject: [PATCH 2/4] docs: note Review queue seed/E2E and CODE_DERIVED CHECK repair Co-authored-by: Venkat SF --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index cff7764..13eb0f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,6 +191,11 @@ only covers cloud-specific, non-obvious caveats. `sudo -u postgres psql -f docker/postgres/init/11_create_acme_erp.sql` then `bash scripts/seed-acme-erp.sh` (registers `ACME ERP (Multi-Schema)` when backend auth is disabled or you have an admin session cookie). +- **Company Knowledge → Review queue** (code-scan suggestions): seed without an LLM via + `python3 scripts/self-host/seed-review-suggestions.py --count 50`, then exercise approve/ + reject/bulk edge cases with `python3 scripts/self-host/e2e-review-approvals.py`. + Approving `SCHEMA_DOC` rows needs `CODE_DERIVED` on `schema_documentation_source_check` + (startup initializer repairs this; Hibernate `ddl-auto` does not). ### Non-obvious setup caveats (each cost real debugging time) From b74b90ef0e5da5b75555d6a752b1fb8095eb50f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 06:46:37 +0000 Subject: [PATCH 3/4] feat: bubble review UI, merge overlapping context rules, non-blocking chat - Show code-scan suggestions as clickable bubbles with rule excerpt preview - Fold scan proposals into one intent when they overlap existing knowledge or schema notes - Approve merges into existing entries/docs instead of creating duplicates - Clarify review is optional; Agent chat shows a non-blocking pending hint Co-authored-by: Venkat SF --- .../service/codescan/CodeScanService.java | 13 +- .../codescan/CodeSuggestionAggregator.java | 420 +++++++++++++++--- .../codescan/CodeSuggestionApplier.java | 103 +++++ .../CodeSuggestionAggregatorTest.java | 65 +++ .../codescan/CodeSuggestionApplierTest.java | 4 + src/components/AgentChat/AgentChatPanel.jsx | 21 +- .../AgentChat/AgentChatPanel.module.css | 14 + .../CompanyKnowledgePanel.jsx | 6 +- .../CompanyKnowledgePanel.module.css | 195 +++++++- .../company-knowledge/SuggestionsQueueTab.jsx | 155 ++++--- 10 files changed, 883 insertions(+), 113 deletions(-) diff --git a/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java b/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java index e9e0513..2c4da80 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java @@ -9,6 +9,8 @@ import com.dbaagent.repository.CodeScanFileHashRepository; import com.dbaagent.repository.CodeScanJobRepository; import com.dbaagent.repository.CodeScanSourceRepository; +import com.dbaagent.repository.CompanyKnowledgeEntryRepository; +import com.dbaagent.repository.SchemaDocumentationRepository; import com.dbaagent.service.SchemaScannerService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -66,6 +68,8 @@ public class CodeScanService { private final CodeSuggestionApplier applier; private final SchemaScannerService schemaScannerService; private final SchemaAmbiguityService schemaAmbiguityService; + private final CompanyKnowledgeEntryRepository companyKnowledgeEntryRepository; + private final SchemaDocumentationRepository schemaDocumentationRepository; @Value("${code-scan.executor.core:2}") private int executorCore; @@ -492,7 +496,14 @@ private void runJob(String jobId, String connectionId, Path workdir, String sour j.setProgress(90); }); - List aggregated = aggregator.aggregate(connectionId, jobId, raw, schema); + List aggregated = aggregator.aggregate( + connectionId, + jobId, + raw, + schema, + companyKnowledgeEntryRepository.findByConnectionId(connectionId), + schemaDocumentationRepository.findByConnectionId(connectionId) + ); if (!aggregated.isEmpty()) { suggestionRepository.saveAll(aggregated); supersedeStalePending(connectionId, sourceId, jobId, aggregated); diff --git a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionAggregator.java b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionAggregator.java index 8755590..ac68499 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionAggregator.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionAggregator.java @@ -1,5 +1,7 @@ package com.dbaagent.service.codescan; +import com.dbaagent.model.CompanyKnowledgeEntry; +import com.dbaagent.model.SchemaDocumentation; import com.dbaagent.model.SchemaMetadata; import com.dbaagent.model.TableMetadata; import com.dbaagent.model.code.CodeKnowledgeSuggestion; @@ -22,6 +24,8 @@ * - Validates targetTable / targetColumn against the live schema (drops misses). * - Groups by (targetKind, targetObject) so multiple proposals for the same * table/column collapse into one row with alternates attached. + * - Merges against existing company-knowledge entries and schema notes so the + * review queue does not surface overlapping intents. * - Carries through provenance ({@code sourceFiles}) for the review UI. */ @Component @@ -33,6 +37,17 @@ public List aggregate( String jobId, List raw, SchemaMetadata schema + ) { + return aggregate(connectionId, jobId, raw, schema, List.of(), List.of()); + } + + public List aggregate( + String connectionId, + String jobId, + List raw, + SchemaMetadata schema, + List existingKnowledge, + List existingDocs ) { if (raw == null || raw.isEmpty()) return List.of(); Map tableLookup = buildTableLookup(schema); @@ -60,68 +75,172 @@ public List aggregate( grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(s); } - List out = new ArrayList<>(); + ExistingContextIndex existingIndex = new ExistingContextIndex(existingKnowledge, existingDocs); + Map byIntent = new LinkedHashMap<>(); + for (var entry : grouped.entrySet()) { List bucket = entry.getValue(); bucket.sort(Comparator.comparingDouble((CodeKnowledgeExtractor.RawSuggestion r) -> r.confidence).reversed()); - CodeKnowledgeExtractor.RawSuggestion primary = bucket.get(0); - - CodeKnowledgeSuggestion suggestion = CodeKnowledgeSuggestion.builder() - .jobId(jobId) - .connectionId(connectionId) - .targetKind(resolveTargetKind(primary)) - .targetObject(resolveTargetObject(primary)) - .title(primary.title) - .content(primary.content) - .linkedTables(primary.linkedTables) - .linkedColumns(primary.linkedColumns) - .confidence(clampConfidence(primary.confidence)) - .status(CodeKnowledgeSuggestion.Status.PENDING) - .build(); - - // payload carries entry type, businessTerms, and alternates - Map payload = new LinkedHashMap<>(); - if (primary.entryType != null) payload.put("entryType", primary.entryType); - if (primary.objectKind != null) payload.put("objectKind", primary.objectKind); - if (!primary.businessTerms.isEmpty()) payload.put("businessTerms", primary.businessTerms); - if (primary.rationale != null) payload.put("rationale", primary.rationale); - - if (bucket.size() > 1) { - List> alternates = new ArrayList<>(); - for (int i = 1; i < bucket.size(); i++) { - var alt = bucket.get(i); - Map a = new LinkedHashMap<>(); - a.put("title", alt.title); - a.put("content", alt.content); - a.put("confidence", alt.confidence); - a.put("rationale", alt.rationale); - a.put("sourcePath", alt.sourcePath); - a.put("sourceStartLine", alt.sourceStartLine); - a.put("sourceEndLine", alt.sourceEndLine); - alternates.add(a); + CodeKnowledgeSuggestion suggestion = buildSuggestion(connectionId, jobId, bucket); + String intentKey = existingIndex.intentKeyFor(suggestion); + byIntent.merge(intentKey, suggestion, this::mergeSuggestions); + } + + return new ArrayList<>(byIntent.values()); + } + + private CodeKnowledgeSuggestion buildSuggestion( + String connectionId, + String jobId, + List bucket + ) { + CodeKnowledgeExtractor.RawSuggestion primary = bucket.get(0); + + CodeKnowledgeSuggestion suggestion = CodeKnowledgeSuggestion.builder() + .jobId(jobId) + .connectionId(connectionId) + .targetKind(resolveTargetKind(primary)) + .targetObject(resolveTargetObject(primary)) + .title(primary.title) + .content(primary.content) + .linkedTables(primary.linkedTables) + .linkedColumns(primary.linkedColumns) + .confidence(clampConfidence(primary.confidence)) + .status(CodeKnowledgeSuggestion.Status.PENDING) + .build(); + + Map payload = new LinkedHashMap<>(); + if (primary.entryType != null) payload.put("entryType", primary.entryType); + if (primary.objectKind != null) payload.put("objectKind", primary.objectKind); + if (!primary.businessTerms.isEmpty()) payload.put("businessTerms", primary.businessTerms); + if (primary.rationale != null) payload.put("rationale", primary.rationale); + + if (bucket.size() > 1) { + payload.put("alternatives", buildAlternates(bucket.subList(1, bucket.size()))); + } + suggestion.setPayload(payload); + suggestion.setSourceFiles(buildSourceFiles(bucket)); + return suggestion; + } + + private CodeKnowledgeSuggestion mergeSuggestions(CodeKnowledgeSuggestion primary, CodeKnowledgeSuggestion other) { + if (other == null) return primary; + if (primary == null) return other; + + if ((other.getConfidence() != null ? other.getConfidence() : 0) + > (primary.getConfidence() != null ? primary.getConfidence() : 0)) { + CodeKnowledgeSuggestion swap = primary; + primary = other; + other = swap; + } + + Map payload = payloadOrNew(primary); + appendAlternate(payload, other); + + if (other.getPayload() != null && other.getPayload().get("alternatives") instanceof List alts) { + for (Object alt : alts) { + if (alt instanceof Map map) { + @SuppressWarnings("unchecked") + Map cast = (Map) map; + appendAlternateMap(payload, cast); } - payload.put("alternatives", alternates); } - suggestion.setPayload(payload); + } - // Source files: primary chunk + dedup of alt chunks - List> sources = new ArrayList<>(); - Set seenSources = new HashSet<>(); - for (var item : bucket) { - String key = item.sourcePath + ":" + item.sourceStartLine + ":" + item.sourceEndLine; - if (!seenSources.add(key)) continue; - Map sf = new LinkedHashMap<>(); - sf.put("path", item.sourcePath); - sf.put("startLine", item.sourceStartLine); - sf.put("endLine", item.sourceEndLine); - if (item.rationale != null) sf.put("rationale", item.rationale); - sources.add(sf); + primary.setPayload(payload); + primary.setSourceFiles(mergeSourceFiles(primary.getSourceFiles(), other.getSourceFiles())); + mergeExistingContextMarkers(payload, other.getPayload()); + return primary; + } + + private static void appendAlternate(Map payload, CodeKnowledgeSuggestion other) { + Map alt = new LinkedHashMap<>(); + alt.put("title", other.getTitle()); + alt.put("content", other.getContent()); + alt.put("confidence", other.getConfidence()); + if (other.getPayload() != null && other.getPayload().get("rationale") != null) { + alt.put("rationale", other.getPayload().get("rationale")); + } + appendAlternateMap(payload, alt); + } + + @SuppressWarnings("unchecked") + private static void appendAlternateMap(Map payload, Map alt) { + List> alternates = (List>) payload.computeIfAbsent( + "alternatives", + k -> new ArrayList<>() + ); + alternates.add(alt); + } + + private static void mergeExistingContextMarkers(Map target, Map source) { + if (source == null) return; + if (Boolean.TRUE.equals(source.get("mergedWithExisting"))) { + target.put("mergedWithExisting", true); + } + for (String key : List.of("existingEntryId", "existingDocId", "existingTitle", "existingExcerpt")) { + if (source.get(key) != null && target.get(key) == null) { + target.put(key, source.get(key)); } - suggestion.setSourceFiles(sources); + } + } - out.add(suggestion); + private static List> buildAlternates(List bucket) { + List> alternates = new ArrayList<>(); + for (var alt : bucket) { + Map a = new LinkedHashMap<>(); + a.put("title", alt.title); + a.put("content", alt.content); + a.put("confidence", alt.confidence); + a.put("rationale", alt.rationale); + a.put("sourcePath", alt.sourcePath); + a.put("sourceStartLine", alt.sourceStartLine); + a.put("sourceEndLine", alt.sourceEndLine); + alternates.add(a); } - return out; + return alternates; + } + + private static List> buildSourceFiles(List bucket) { + List> sources = new ArrayList<>(); + Set seenSources = new HashSet<>(); + for (var item : bucket) { + String key = item.sourcePath + ":" + item.sourceStartLine + ":" + item.sourceEndLine; + if (!seenSources.add(key)) continue; + Map sf = new LinkedHashMap<>(); + sf.put("path", item.sourcePath); + sf.put("startLine", item.sourceStartLine); + sf.put("endLine", item.sourceEndLine); + if (item.rationale != null) sf.put("rationale", item.rationale); + sources.add(sf); + } + return sources; + } + + @SuppressWarnings("unchecked") + private static List> mergeSourceFiles( + List> left, + List> right + ) { + List> merged = new ArrayList<>(); + Set seen = new HashSet<>(); + for (List> list : List.of(left, right)) { + if (list == null) continue; + for (Map sf : list) { + String key = sf.get("path") + ":" + sf.get("startLine") + ":" + sf.get("endLine"); + if (seen.add(key)) merged.add(sf); + } + } + return merged; + } + + private static Map payloadOrNew(CodeKnowledgeSuggestion suggestion) { + Map payload = suggestion.getPayload(); + if (payload == null) { + payload = new LinkedHashMap<>(); + suggestion.setPayload(payload); + } + return payload; } private static String groupKey(CodeKnowledgeExtractor.RawSuggestion s) { @@ -129,8 +248,7 @@ private static String groupKey(CodeKnowledgeExtractor.RawSuggestion s) { String table = s.targetTable == null ? "" : s.targetTable.toLowerCase(Locale.ROOT); String col = s.targetColumn == null ? "" : s.targetColumn.toLowerCase(Locale.ROOT); if ("KNOWLEDGE_ENTRY".equals(kind) && col.isEmpty() && table.isEmpty() && s.title != null) { - // narrative entries with no schema target — group by title to dedupe near-duplicates - return kind + "|TITLE|" + s.title.toLowerCase(Locale.ROOT); + return kind + "|TITLE|" + normalizeText(s.title); } return kind + "|" + table + "|" + col; } @@ -170,7 +288,6 @@ private static Map buildTableLookup(SchemaMetadata schema) { return lookup; } - /** Maps lowercase "table.column" → canonical "table.column" string from the schema. */ private static Map buildColumnLookup(SchemaMetadata schema) { Map lookup = new HashMap<>(); if (schema == null || schema.getTables() == null) return lookup; @@ -184,4 +301,195 @@ private static Map buildColumnLookup(SchemaMetadata schema) { } return lookup; } + + private static String normalizeText(String value) { + if (value == null) return ""; + return value.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", " "); + } + + /** + * Indexes live knowledge-base rows so new scan proposals can be folded into a + * single review intent when they overlap an existing rule or schema note. + */ + static final class ExistingContextIndex { + private final Map knowledgeByTitle = new HashMap<>(); + private final Map knowledgeByLink = new HashMap<>(); + private final Map docsByTarget = new HashMap<>(); + + ExistingContextIndex(List knowledge, List docs) { + if (knowledge != null) { + for (CompanyKnowledgeEntry entry : knowledge) { + if (entry == null || entry.getId() == null) continue; + if (entry.getTitle() != null) { + knowledgeByTitle.put(normalizeText(entry.getTitle()), entry); + } + indexKnowledgeLinks(entry); + } + } + if (docs != null) { + for (SchemaDocumentation doc : docs) { + if (doc == null || doc.getId() == null) continue; + String key = schemaDocTargetKey(doc); + if (!key.isBlank()) { + docsByTarget.put(key, doc); + } + } + } + } + + private void indexKnowledgeLinks(CompanyKnowledgeEntry entry) { + String entryType = entry.getEntryType() == null + ? "business_rule" + : entry.getEntryType().name().toLowerCase(Locale.ROOT); + if (entry.getLinkedColumns() != null) { + for (String col : entry.getLinkedColumns()) { + if (col == null || col.isBlank()) continue; + knowledgeByLink.put("col|" + entryType + "|" + normalizeText(col), entry); + } + } + if (entry.getLinkedTables() != null) { + for (String table : entry.getLinkedTables()) { + if (table == null || table.isBlank()) continue; + knowledgeByLink.put("table|" + entryType + "|" + normalizeText(table), entry); + } + } + } + + String intentKeyFor(CodeKnowledgeSuggestion suggestion) { + ExistingMatch match = findMatch(suggestion); + if (match != null) { + annotateExisting(suggestion, match); + return match.intentKey(); + } + return internalIntentKey(suggestion); + } + + private ExistingMatch findMatch(CodeKnowledgeSuggestion suggestion) { + if (suggestion.getTargetKind() == CodeKnowledgeSuggestion.TargetKind.SCHEMA_DOC) { + String key = normalizeSchemaTarget(suggestion.getTargetObject()); + SchemaDocumentation doc = docsByTarget.get(key); + if (doc != null) { + return ExistingMatch.forDoc(doc); + } + return null; + } + + CompanyKnowledgeEntry byTitle = suggestion.getTitle() == null + ? null + : knowledgeByTitle.get(normalizeText(suggestion.getTitle())); + if (byTitle != null) { + return ExistingMatch.forEntry(byTitle); + } + + String entryType = resolveEntryTypeKey(suggestion); + if (suggestion.getLinkedColumns() != null) { + for (String col : suggestion.getLinkedColumns()) { + CompanyKnowledgeEntry hit = knowledgeByLink.get("col|" + entryType + "|" + normalizeText(col)); + if (hit != null) return ExistingMatch.forEntry(hit); + } + } + if (suggestion.getLinkedTables() != null) { + for (String table : suggestion.getLinkedTables()) { + CompanyKnowledgeEntry hit = knowledgeByLink.get("table|" + entryType + "|" + normalizeText(table)); + if (hit != null) return ExistingMatch.forEntry(hit); + } + } + if (suggestion.getTargetObject() != null) { + CompanyKnowledgeEntry hit = knowledgeByLink.get( + "table|" + entryType + "|" + normalizeText(suggestion.getTargetObject()) + ); + if (hit != null) return ExistingMatch.forEntry(hit); + } + return null; + } + + private static void annotateExisting(CodeKnowledgeSuggestion suggestion, ExistingMatch match) { + Map payload = suggestion.getPayload(); + if (payload == null) { + payload = new LinkedHashMap<>(); + suggestion.setPayload(payload); + } + payload.put("mergedWithExisting", true); + if (match.entry() != null) { + payload.put("existingEntryId", match.entry().getId()); + payload.put("existingTitle", match.entry().getTitle()); + payload.put("existingExcerpt", excerpt(match.entry().getContent())); + } + if (match.doc() != null) { + payload.put("existingDocId", match.doc().getId()); + payload.put("existingTitle", schemaDocLabel(match.doc())); + payload.put("existingExcerpt", excerpt(match.doc().getDescription())); + } + } + + private static String internalIntentKey(CodeKnowledgeSuggestion suggestion) { + String kind = suggestion.getTargetKind() == null ? "KNOWLEDGE_ENTRY" : suggestion.getTargetKind().name(); + String target = suggestion.getTargetObject() == null ? "" : normalizeText(suggestion.getTargetObject()); + String title = suggestion.getTitle() == null ? "" : normalizeText(suggestion.getTitle()); + return "NEW|" + kind + "|" + target + "|" + title; + } + + private static String resolveEntryTypeKey(CodeKnowledgeSuggestion suggestion) { + Object raw = suggestion.getPayload() == null ? null : suggestion.getPayload().get("entryType"); + if (raw == null) return "business_rule"; + return raw.toString().trim().toLowerCase(Locale.ROOT); + } + + private static String schemaDocTargetKey(SchemaDocumentation doc) { + if (doc.getObjectType() == SchemaDocumentation.DocumentationType.COLUMN) { + String parent = bareName(doc.getParentObject()); + return normalizeSchemaTarget(parent + "." + doc.getObjectName()); + } + return normalizeSchemaTarget(doc.getObjectName()); + } + + private static String schemaDocLabel(SchemaDocumentation doc) { + if (doc.getObjectType() == SchemaDocumentation.DocumentationType.COLUMN) { + return bareName(doc.getParentObject()) + "." + doc.getObjectName(); + } + return doc.getObjectName(); + } + + private static String bareName(String value) { + if (value == null) return ""; + int dot = value.lastIndexOf('.'); + return dot >= 0 ? value.substring(dot + 1) : value; + } + + private static String normalizeSchemaTarget(String target) { + if (target == null) return ""; + String trimmed = target.trim().toLowerCase(Locale.ROOT); + int dot = trimmed.lastIndexOf('.'); + if (dot < 0) return trimmed; + String left = trimmed.substring(0, dot); + String right = trimmed.substring(dot + 1); + int leftDot = left.lastIndexOf('.'); + if (leftDot >= 0) { + left = left.substring(leftDot + 1); + } + return left + "." + right; + } + + private static String excerpt(String text) { + if (text == null) return ""; + String trimmed = text.trim(); + if (trimmed.length() <= 480) return trimmed; + return trimmed.substring(0, 477) + "…"; + } + } + + private record ExistingMatch(CompanyKnowledgeEntry entry, SchemaDocumentation doc) { + static ExistingMatch forEntry(CompanyKnowledgeEntry entry) { + return new ExistingMatch(entry, null); + } + + static ExistingMatch forDoc(SchemaDocumentation doc) { + return new ExistingMatch(null, doc); + } + + String intentKey() { + if (entry != null) return "EXISTING_ENTRY|" + entry.getId(); + return "EXISTING_DOC|" + doc.getId(); + } + } } diff --git a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java index 174bf64..a1ef661 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java @@ -5,6 +5,7 @@ import com.dbaagent.model.SchemaDocumentation; import com.dbaagent.model.code.CodeKnowledgeSuggestion; import com.dbaagent.repository.CodeKnowledgeSuggestionRepository; +import com.dbaagent.repository.CompanyKnowledgeEntryRepository; import com.dbaagent.repository.SchemaDocumentationRepository; import com.dbaagent.service.CompanyKnowledgeService; import com.dbaagent.service.SchemaScannerService; @@ -15,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -32,6 +34,7 @@ public class CodeSuggestionApplier { private final CodeKnowledgeSuggestionRepository suggestionRepository; private final SchemaDocumentationRepository schemaDocRepository; + private final CompanyKnowledgeEntryRepository companyKnowledgeEntryRepository; private final CompanyKnowledgeService companyKnowledgeService; private final TrainingService trainingService; private final SchemaScannerService schemaScannerService; @@ -78,6 +81,36 @@ public CodeKnowledgeSuggestion reject(String suggestionId, String decidedBy, Str } private void applySchemaDoc(CodeKnowledgeSuggestion suggestion, String decidedBy) { + Map payload = suggestion.getPayload(); + Object existingDocId = payload == null ? null : payload.get("existingDocId"); + if (existingDocId != null && !existingDocId.toString().isBlank()) { + SchemaDocumentation doc = schemaDocRepository.findById(existingDocId.toString()) + .orElseThrow(() -> new IllegalArgumentException( + "Existing schema doc not found: " + existingDocId + )); + doc.setDescription(mergeText(doc.getDescription(), suggestion.getContent())); + Object terms = payload.get("businessTerms"); + if (terms instanceof List list && !list.isEmpty()) { + String mergedTerms = mergeCommaSeparated(doc.getBusinessTerms(), list); + doc.setBusinessTerms(mergedTerms); + } + if (doc.getConfidence() == null || (suggestion.getConfidence() != null + && suggestion.getConfidence() > doc.getConfidence())) { + doc.setConfidence(suggestion.getConfidence()); + } + if (suggestion.getSourceFiles() != null && !suggestion.getSourceFiles().isEmpty()) { + doc.setSourceFiles(suggestion.getSourceFiles()); + } + SchemaDocumentation saved = schemaDocRepository.save(doc); + suggestion.setAppliedDocId(saved.getId()); + try { + trainingService.upsertDocumentationEmbedding(saved); + } catch (Exception e) { + log.warn("Failed to embed schema doc {} after approval: {}", saved.getId(), e.getMessage()); + } + return; + } + SchemaDocumentation.DocumentationType objectType = resolveObjectType(suggestion); String objectName; String parentObject = null; @@ -166,6 +199,27 @@ private void applySchemaDoc(CodeKnowledgeSuggestion suggestion, String decidedBy } private void applyKnowledgeEntry(CodeKnowledgeSuggestion suggestion, String decidedBy) { + Map payload = suggestion.getPayload(); + Object existingId = payload == null ? null : payload.get("existingEntryId"); + if (existingId != null && !existingId.toString().isBlank()) { + CompanyKnowledgeEntry existing = companyKnowledgeEntryRepository.findById(existingId.toString()) + .orElseThrow(() -> new IllegalArgumentException( + "Existing company knowledge entry not found: " + existingId + )); + CompanyKnowledgeEntry merged = CompanyKnowledgeEntry.builder() + .connectionId(existing.getConnectionId()) + .title(pickTitle(existing.getTitle(), suggestion.getTitle())) + .content(mergeText(existing.getContent(), suggestion.getContent())) + .entryType(resolveEntryType(suggestion)) + .linkedTables(mergeStringLists(existing.getLinkedTables(), suggestion.getLinkedTables())) + .linkedColumns(mergeStringLists(existing.getLinkedColumns(), suggestion.getLinkedColumns())) + .createdBy(decidedBy) + .build(); + CompanyKnowledgeEntry saved = companyKnowledgeService.updateEntry(existing.getId(), merged); + suggestion.setAppliedEntryId(saved.getId()); + return; + } + CompanyKnowledgeEntry entry = CompanyKnowledgeEntry.builder() .connectionId(suggestion.getConnectionId()) .title(suggestion.getTitle()) @@ -220,4 +274,53 @@ private static CompanyKnowledgeEntry.EntryType resolveEntryType(CodeKnowledgeSug } return CompanyKnowledgeEntry.EntryType.BUSINESS_RULE; } + + private static String pickTitle(String existing, String proposed) { + if (proposed == null || proposed.isBlank()) return existing; + if (existing == null || existing.isBlank()) return proposed; + return proposed.length() >= existing.length() ? proposed : existing; + } + + private static String mergeText(String existing, String addition) { + if (addition == null || addition.isBlank()) { + return existing == null ? "" : existing; + } + if (existing == null || existing.isBlank()) { + return addition.trim(); + } + String left = existing.trim(); + String right = addition.trim(); + if (left.equalsIgnoreCase(right)) return left; + if (left.toLowerCase().contains(right.toLowerCase())) return left; + if (right.toLowerCase().contains(left.toLowerCase())) return right; + return left + "\n\n" + right; + } + + private static List mergeStringLists(List left, List right) { + List merged = new ArrayList<>(); + if (left != null) merged.addAll(left); + if (right != null) { + for (String value : right) { + if (value != null && !value.isBlank() && !merged.contains(value)) { + merged.add(value); + } + } + } + return merged; + } + + private static String mergeCommaSeparated(String existing, List additions) { + List merged = new ArrayList<>(); + if (existing != null && !existing.isBlank()) { + for (String part : existing.split(",")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty() && !merged.contains(trimmed)) merged.add(trimmed); + } + } + for (Object item : additions) { + String value = String.valueOf(item).trim(); + if (!value.isEmpty() && !merged.contains(value)) merged.add(value); + } + return String.join(", ", merged); + } } diff --git a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionAggregatorTest.java b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionAggregatorTest.java index 8cd2964..15bda8d 100644 --- a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionAggregatorTest.java +++ b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionAggregatorTest.java @@ -1,6 +1,8 @@ package com.dbaagent.service.codescan; import com.dbaagent.model.ColumnMetadata; +import com.dbaagent.model.CompanyKnowledgeEntry; +import com.dbaagent.model.SchemaDocumentation; import com.dbaagent.model.SchemaMetadata; import com.dbaagent.model.TableMetadata; import com.dbaagent.model.code.CodeKnowledgeSuggestion; @@ -89,6 +91,69 @@ void dropsColumnSuggestionWhenColumnUnknown() { assertTrue(aggregator.aggregate("c1", "j1", List.of(raw), schemaWithBookings()).isEmpty()); } + @Test + void mergesWithExistingSchemaDocIntent() { + SchemaDocumentation existing = SchemaDocumentation.builder() + .id("doc-1") + .connectionId("c1") + .objectType(SchemaDocumentation.DocumentationType.COLUMN) + .objectName("status") + .parentObject("analytics_db.bookings") + .description("Existing lifecycle note.") + .build(); + + var raw = new CodeKnowledgeExtractor.RawSuggestion(); + raw.kind = "SCHEMA_DOC"; + raw.targetTable = "bookings"; + raw.targetColumn = "status"; + raw.title = "Booking status"; + raw.content = "Reservation state machine from code."; + raw.confidence = 0.82; + + var out = aggregator.aggregate( + "c1", + "j1", + List.of(raw), + schemaWithBookings(), + List.of(), + List.of(existing) + ); + assertEquals(1, out.size()); + assertTrue(Boolean.TRUE.equals(out.get(0).getPayload().get("mergedWithExisting"))); + assertEquals("doc-1", out.get(0).getPayload().get("existingDocId")); + assertEquals("Existing lifecycle note.", out.get(0).getPayload().get("existingExcerpt")); + } + + @Test + void mergesWithExistingKnowledgeEntryByTitle() { + CompanyKnowledgeEntry existing = CompanyKnowledgeEntry.builder() + .id("entry-1") + .connectionId("c1") + .title("Refund policy") + .entryType(CompanyKnowledgeEntry.EntryType.BUSINESS_RULE) + .content("Refunds are allowed within 30 days.") + .build(); + + var raw = new CodeKnowledgeExtractor.RawSuggestion(); + raw.kind = "KNOWLEDGE_ENTRY"; + raw.title = "Refund policy"; + raw.content = "Partial refunds require manager approval."; + raw.confidence = 0.77; + raw.entryType = "BUSINESS_RULE"; + + var out = aggregator.aggregate( + "c1", + "j1", + List.of(raw), + schemaWithBookings(), + List.of(existing), + List.of() + ); + assertEquals(1, out.size()); + assertTrue(Boolean.TRUE.equals(out.get(0).getPayload().get("mergedWithExisting"))); + assertEquals("entry-1", out.get(0).getPayload().get("existingEntryId")); + } + private static ColumnMetadata col(String name, String dataType) { ColumnMetadata c = new ColumnMetadata(); c.setName(name); diff --git a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java index 773806f..7aa2bfd 100644 --- a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java +++ b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java @@ -1,10 +1,12 @@ package com.dbaagent.service.codescan; +import com.dbaagent.model.CompanyKnowledgeEntry; import com.dbaagent.model.DocumentationSource; import com.dbaagent.model.SchemaDocumentation; import com.dbaagent.model.SchemaMetadata; import com.dbaagent.model.code.CodeKnowledgeSuggestion; import com.dbaagent.repository.CodeKnowledgeSuggestionRepository; +import com.dbaagent.repository.CompanyKnowledgeEntryRepository; import com.dbaagent.repository.SchemaDocumentationRepository; import com.dbaagent.service.CompanyKnowledgeService; import com.dbaagent.service.SchemaScannerService; @@ -33,6 +35,7 @@ class CodeSuggestionApplierTest { @Mock private CodeKnowledgeSuggestionRepository suggestionRepository; @Mock private SchemaDocumentationRepository schemaDocRepository; + @Mock private CompanyKnowledgeEntryRepository companyKnowledgeEntryRepository; @Mock private CompanyKnowledgeService companyKnowledgeService; @Mock private TrainingService trainingService; @Mock private SchemaScannerService schemaScannerService; @@ -44,6 +47,7 @@ void setUp() { applier = new CodeSuggestionApplier( suggestionRepository, schemaDocRepository, + companyKnowledgeEntryRepository, companyKnowledgeService, trainingService, schemaScannerService diff --git a/src/components/AgentChat/AgentChatPanel.jsx b/src/components/AgentChat/AgentChatPanel.jsx index 0326c6b..840c2ab 100644 --- a/src/components/AgentChat/AgentChatPanel.jsx +++ b/src/components/AgentChat/AgentChatPanel.jsx @@ -1,7 +1,8 @@ import { useState, useRef, useEffect, useCallback } from 'react' -import { ArrowUp, Plus, Square, Loader2, Database, Sparkles, Hash, Table2, Clock, AlertCircle } from 'lucide-react' +import { ArrowUp, Plus, Square, Loader2, Database, Sparkles, Hash, Table2, Clock, AlertCircle, Inbox } from 'lucide-react' import { agentChatAPI, withConnectionContext } from '@/lib/api/agentClient' import { agentConversationAPI } from '@/lib/api/client' +import { useCodeScanSuggestions } from '@/lib/hooks/queries' import { useAuth } from '@/hooks/useAuth' import AgentMarkdown from './AgentMarkdown' import { sanitizeAssistantAnswer } from './sanitizeAssistantAnswer' @@ -62,6 +63,14 @@ export default function AgentChatPanel({ connectionId, connectionName }) { const convIdRef = useRef(null) // backend conversation id (the per-user index row) const restoredRef = useRef(false) // guards the persist effect until boot finishes + const pendingSuggestionsQuery = useCodeScanSuggestions({ + connectionId, + status: 'PENDING', + page: 0, + size: 1, + }) + const pendingSuggestionCount = pendingSuggestionsQuery.data?.totalElements ?? 0 + const boot = useCallback(async ({ fresh = false } = {}) => { setBooting(true); setBootError(null); setAuthBlocked(false) restoredRef.current = false @@ -198,6 +207,16 @@ export default function AgentChatPanel({ connectionId, connectionName }) { + {pendingSuggestionCount > 0 && ( +

+
+ )} +
{booting && (
diff --git a/src/components/AgentChat/AgentChatPanel.module.css b/src/components/AgentChat/AgentChatPanel.module.css index dd758c4..0a21e2b 100644 --- a/src/components/AgentChat/AgentChatPanel.module.css +++ b/src/components/AgentChat/AgentChatPanel.module.css @@ -33,6 +33,20 @@ } .connBadge svg { flex-shrink: 0; color: #534AB7; } +.reviewHint { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 8px 20px; + font-size: 12.5px; + line-height: 1.45; + color: #5b4b1f; + background: #fffbeb; + border-bottom: 1px solid #fde68a; +} +.reviewHint svg { flex-shrink: 0; color: #b45309; } + .newChat { display: inline-flex; align-items: center; gap: 5px; height: 30px; diff --git a/src/components/company-knowledge/CompanyKnowledgePanel.jsx b/src/components/company-knowledge/CompanyKnowledgePanel.jsx index d031f93..1c323e4 100644 --- a/src/components/company-knowledge/CompanyKnowledgePanel.jsx +++ b/src/components/company-knowledge/CompanyKnowledgePanel.jsx @@ -600,7 +600,9 @@ export default function CompanyKnowledgePanel({ connectionId }) { )} - {pendingSuggestionCount > 0 ? `${pendingSuggestionCount} awaiting sign-off` : 'Accept what fits'} + {pendingSuggestionCount > 0 + ? `${pendingSuggestionCount} optional — review anytime` + : 'Accept what fits'} @@ -623,7 +625,7 @@ export default function CompanyKnowledgePanel({ connectionId }) {

- Knowledge flows left to right — nothing reaches the knowledge base until you review and accept it. + Review is optional and never blocks Agent chat — approve suggestions when you want them in the knowledge base.

{activeTab === 'schema-context' && } diff --git a/src/components/company-knowledge/CompanyKnowledgePanel.module.css b/src/components/company-knowledge/CompanyKnowledgePanel.module.css index 10fca0e..de3d667 100644 --- a/src/components/company-knowledge/CompanyKnowledgePanel.module.css +++ b/src/components/company-knowledge/CompanyKnowledgePanel.module.css @@ -668,7 +668,200 @@ background: #fff; } -/* ===== Suggestions table layout ===== */ +/* ===== Suggestions bubble layout ===== */ + +.suggestionsBubblePanel { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border: 1px solid #e5e7eb; + border-radius: 16px; + background: #fff; + min-height: 200px; +} + +.suggestionsBubbleToolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.bubbleSelectAll { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; + color: #6b7280; +} + +.suggestionBubbles { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-content: flex-start; +} + +.suggestionBubbleWrap { + display: flex; + align-items: flex-start; + gap: 6px; + max-width: 100%; +} + +.suggestionBubbleCheckbox { + display: flex; + align-items: center; + padding-top: 12px; +} + +.suggestionBubble { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + min-width: 180px; + max-width: 280px; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 999px; + background: #f9fafb; + color: #111827; + text-align: left; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; +} + +.suggestionBubble:hover { + background: #fff; + border-color: #c7d2fe; + transform: translateY(-1px); +} + +.suggestionBubbleActive { + background: #eef2ff; + border-color: #6366f1; + box-shadow: 0 8px 24px rgba(99, 102, 241, 0.12); +} + +.suggestionBubbleSelected { + outline: 2px solid #93c5fd; + outline-offset: 2px; +} + +.suggestionBubbleTop { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: 100%; +} + +.suggestionBubbleTitle { + font-size: 13px; + font-weight: 700; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.suggestionBubbleTarget { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + color: #6b7280; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.suggestionBubbleMerge { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #92400e; + background: #fef3c7; + padding: 2px 8px; + border-radius: 999px; +} + +.mergeBadge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + background: #fef3c7; + color: #92400e; +} + +.previewSectionExisting { + padding: 12px; + border-radius: 12px; + background: #fffbeb; + border: 1px solid #fde68a; +} + +.previewSectionExcerpt { + padding: 0; +} + +.previewExistingTitle { + font-size: 13px; + font-weight: 700; + color: #92400e; + margin-bottom: 6px; +} + +.previewBodyMuted { + margin: 0; + font-size: 13px; + line-height: 1.55; + color: #78350f; +} + +.previewMergeHint { + margin: 8px 0 0; + font-size: 12px; + color: #92400e; +} + +.ruleExcerptCard { + padding: 14px; + border-radius: 14px; + border: 1px solid #dbeafe; + background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%); +} + +.ruleExcerptHeadline { + font-size: 14px; + font-weight: 700; + color: #111827; + margin-bottom: 8px; +} + +.ruleExcerptBody { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: #374151; + white-space: pre-wrap; +} + +.ruleExcerptMeta { + margin-top: 10px; + font-size: 12px; + color: #6b7280; +} + +/* ===== Suggestions table layout (legacy rows kept for reference) ===== */ .suggestionsLayout { display: grid; diff --git a/src/components/company-knowledge/SuggestionsQueueTab.jsx b/src/components/company-knowledge/SuggestionsQueueTab.jsx index a15ef6d..9e789db 100644 --- a/src/components/company-knowledge/SuggestionsQueueTab.jsx +++ b/src/components/company-knowledge/SuggestionsQueueTab.jsx @@ -48,12 +48,34 @@ function buildSearchHaystack(s) { return parts.join(' ').toLowerCase() } +function buildRuleExcerpt(suggestion) { + if (!suggestion) return null + const entryType = suggestion.payload?.entryType || 'BUSINESS_RULE' + if (suggestion.targetKind === 'SCHEMA_DOC') { + const target = suggestion.targetObject || 'schema object' + return { + label: 'Schema note that would be saved', + headline: target, + body: suggestion.content, + meta: suggestion.payload?.businessTerms?.length + ? `Terms: ${suggestion.payload.businessTerms.join(', ')}` + : null, + } + } + return { + label: `${String(entryType).replaceAll('_', ' ').toLowerCase()} that would be saved`, + headline: suggestion.title, + body: suggestion.content, + meta: suggestion.targetObject ? `Linked: ${suggestion.targetObject}` : null, + } +} + function PreviewPane({ suggestion, pinned, onPin, onUnpin, onDecide, decidePending }) { if (!suggestion) { return ( ) @@ -63,6 +85,10 @@ function PreviewPane({ suggestion, pinned, onPin, onUnpin, onDecide, decidePendi const sources = suggestion.sourceFiles || [] const businessTerms = suggestion.payload?.businessTerms || [] const rationale = suggestion.payload?.rationale + const excerpt = buildRuleExcerpt(suggestion) + const mergedWithExisting = Boolean(suggestion.payload?.mergedWithExisting) + const existingExcerpt = suggestion.payload?.existingExcerpt + const existingTitle = suggestion.payload?.existingTitle return ( ) } @@ -180,7 +231,7 @@ export default function SuggestionsQueueTab({ connectionId }) { const [search, setSearch] = useState('') const [minConfidence, setMinConfidence] = useState(0) const [selected, setSelected] = useState(() => new Set()) - const [hoveredId, setHoveredId] = useState(null) + const [activeId, setActiveId] = useState(null) const [pinnedId, setPinnedId] = useState(null) const [bulkError, setBulkError] = useState(null) const [bulkSuccess, setBulkSuccess] = useState(null) @@ -218,7 +269,7 @@ export default function SuggestionsQueueTab({ connectionId }) { const allVisibleSelected = filtered.length > 0 && filtered.every((s) => selected.has(s.id)) const someVisibleSelected = !allVisibleSelected && filtered.some((s) => selected.has(s.id)) - const focusedId = pinnedId || hoveredId + const focusedId = pinnedId || activeId || filtered[0]?.id || null const focused = useMemo( () => indexed.find((s) => s.id === focusedId) || null, [indexed, focusedId], @@ -419,9 +470,9 @@ export default function SuggestionsQueueTab({ connectionId }) {
) : (
-
-
-
+
+
+
-
Conf
-
Kind
-
Target
-
Title
+ Select all visible +
{filtered.length === 0 ? (
Nothing matches that filter.
) : ( - filtered.map((s) => { - const isSel = selected.has(s.id) - const isPin = pinnedId === s.id - return ( -
setHoveredId(s.id)} - onClick={(e) => { - // Clicks inside a checkbox / button shouldn't toggle pin. - if (e.target.closest('button, input')) return - setPinnedId((cur) => (cur === s.id ? null : s.id)) - }} - > -
e.stopPropagation()}> - toggleOne(s.id)} - aria-label={`select ${s.title}`} - /> +
+ {filtered.map((s) => { + const isSel = selected.has(s.id) + const isActive = focusedId === s.id + const merged = Boolean(s.payload?.mergedWithExisting) + return ( +
+ +
-
- - {Math.round((s.confidence || 0) * 100)} - -
-
- - {kindLabel(s.targetKind)} - -
-
- {s.targetObject || '—'} -
-
{s.title}
-
- ) - }) + ) + })} +
)}
From 7f9c543a51f661cf82426c469551b250582d2d2a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 07:47:40 +0000 Subject: [PATCH 4/4] Revert "feat: bubble review UI, merge overlapping context rules, non-blocking chat" This reverts commit b74b90ef0e5da5b75555d6a752b1fb8095eb50f5. --- .../service/codescan/CodeScanService.java | 13 +- .../codescan/CodeSuggestionAggregator.java | 420 +++--------------- .../codescan/CodeSuggestionApplier.java | 103 ----- .../CodeSuggestionAggregatorTest.java | 65 --- .../codescan/CodeSuggestionApplierTest.java | 4 - src/components/AgentChat/AgentChatPanel.jsx | 21 +- .../AgentChat/AgentChatPanel.module.css | 14 - .../CompanyKnowledgePanel.jsx | 6 +- .../CompanyKnowledgePanel.module.css | 195 +------- .../company-knowledge/SuggestionsQueueTab.jsx | 155 +++---- 10 files changed, 113 insertions(+), 883 deletions(-) diff --git a/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java b/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java index 2c4da80..e9e0513 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java @@ -9,8 +9,6 @@ import com.dbaagent.repository.CodeScanFileHashRepository; import com.dbaagent.repository.CodeScanJobRepository; import com.dbaagent.repository.CodeScanSourceRepository; -import com.dbaagent.repository.CompanyKnowledgeEntryRepository; -import com.dbaagent.repository.SchemaDocumentationRepository; import com.dbaagent.service.SchemaScannerService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -68,8 +66,6 @@ public class CodeScanService { private final CodeSuggestionApplier applier; private final SchemaScannerService schemaScannerService; private final SchemaAmbiguityService schemaAmbiguityService; - private final CompanyKnowledgeEntryRepository companyKnowledgeEntryRepository; - private final SchemaDocumentationRepository schemaDocumentationRepository; @Value("${code-scan.executor.core:2}") private int executorCore; @@ -496,14 +492,7 @@ private void runJob(String jobId, String connectionId, Path workdir, String sour j.setProgress(90); }); - List aggregated = aggregator.aggregate( - connectionId, - jobId, - raw, - schema, - companyKnowledgeEntryRepository.findByConnectionId(connectionId), - schemaDocumentationRepository.findByConnectionId(connectionId) - ); + List aggregated = aggregator.aggregate(connectionId, jobId, raw, schema); if (!aggregated.isEmpty()) { suggestionRepository.saveAll(aggregated); supersedeStalePending(connectionId, sourceId, jobId, aggregated); diff --git a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionAggregator.java b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionAggregator.java index ac68499..8755590 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionAggregator.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionAggregator.java @@ -1,7 +1,5 @@ package com.dbaagent.service.codescan; -import com.dbaagent.model.CompanyKnowledgeEntry; -import com.dbaagent.model.SchemaDocumentation; import com.dbaagent.model.SchemaMetadata; import com.dbaagent.model.TableMetadata; import com.dbaagent.model.code.CodeKnowledgeSuggestion; @@ -24,8 +22,6 @@ * - Validates targetTable / targetColumn against the live schema (drops misses). * - Groups by (targetKind, targetObject) so multiple proposals for the same * table/column collapse into one row with alternates attached. - * - Merges against existing company-knowledge entries and schema notes so the - * review queue does not surface overlapping intents. * - Carries through provenance ({@code sourceFiles}) for the review UI. */ @Component @@ -37,17 +33,6 @@ public List aggregate( String jobId, List raw, SchemaMetadata schema - ) { - return aggregate(connectionId, jobId, raw, schema, List.of(), List.of()); - } - - public List aggregate( - String connectionId, - String jobId, - List raw, - SchemaMetadata schema, - List existingKnowledge, - List existingDocs ) { if (raw == null || raw.isEmpty()) return List.of(); Map tableLookup = buildTableLookup(schema); @@ -75,172 +60,68 @@ public List aggregate( grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(s); } - ExistingContextIndex existingIndex = new ExistingContextIndex(existingKnowledge, existingDocs); - Map byIntent = new LinkedHashMap<>(); - + List out = new ArrayList<>(); for (var entry : grouped.entrySet()) { List bucket = entry.getValue(); bucket.sort(Comparator.comparingDouble((CodeKnowledgeExtractor.RawSuggestion r) -> r.confidence).reversed()); - CodeKnowledgeSuggestion suggestion = buildSuggestion(connectionId, jobId, bucket); - String intentKey = existingIndex.intentKeyFor(suggestion); - byIntent.merge(intentKey, suggestion, this::mergeSuggestions); - } - - return new ArrayList<>(byIntent.values()); - } - - private CodeKnowledgeSuggestion buildSuggestion( - String connectionId, - String jobId, - List bucket - ) { - CodeKnowledgeExtractor.RawSuggestion primary = bucket.get(0); - - CodeKnowledgeSuggestion suggestion = CodeKnowledgeSuggestion.builder() - .jobId(jobId) - .connectionId(connectionId) - .targetKind(resolveTargetKind(primary)) - .targetObject(resolveTargetObject(primary)) - .title(primary.title) - .content(primary.content) - .linkedTables(primary.linkedTables) - .linkedColumns(primary.linkedColumns) - .confidence(clampConfidence(primary.confidence)) - .status(CodeKnowledgeSuggestion.Status.PENDING) - .build(); - - Map payload = new LinkedHashMap<>(); - if (primary.entryType != null) payload.put("entryType", primary.entryType); - if (primary.objectKind != null) payload.put("objectKind", primary.objectKind); - if (!primary.businessTerms.isEmpty()) payload.put("businessTerms", primary.businessTerms); - if (primary.rationale != null) payload.put("rationale", primary.rationale); - - if (bucket.size() > 1) { - payload.put("alternatives", buildAlternates(bucket.subList(1, bucket.size()))); - } - suggestion.setPayload(payload); - suggestion.setSourceFiles(buildSourceFiles(bucket)); - return suggestion; - } - - private CodeKnowledgeSuggestion mergeSuggestions(CodeKnowledgeSuggestion primary, CodeKnowledgeSuggestion other) { - if (other == null) return primary; - if (primary == null) return other; - - if ((other.getConfidence() != null ? other.getConfidence() : 0) - > (primary.getConfidence() != null ? primary.getConfidence() : 0)) { - CodeKnowledgeSuggestion swap = primary; - primary = other; - other = swap; - } - - Map payload = payloadOrNew(primary); - appendAlternate(payload, other); - - if (other.getPayload() != null && other.getPayload().get("alternatives") instanceof List alts) { - for (Object alt : alts) { - if (alt instanceof Map map) { - @SuppressWarnings("unchecked") - Map cast = (Map) map; - appendAlternateMap(payload, cast); + CodeKnowledgeExtractor.RawSuggestion primary = bucket.get(0); + + CodeKnowledgeSuggestion suggestion = CodeKnowledgeSuggestion.builder() + .jobId(jobId) + .connectionId(connectionId) + .targetKind(resolveTargetKind(primary)) + .targetObject(resolveTargetObject(primary)) + .title(primary.title) + .content(primary.content) + .linkedTables(primary.linkedTables) + .linkedColumns(primary.linkedColumns) + .confidence(clampConfidence(primary.confidence)) + .status(CodeKnowledgeSuggestion.Status.PENDING) + .build(); + + // payload carries entry type, businessTerms, and alternates + Map payload = new LinkedHashMap<>(); + if (primary.entryType != null) payload.put("entryType", primary.entryType); + if (primary.objectKind != null) payload.put("objectKind", primary.objectKind); + if (!primary.businessTerms.isEmpty()) payload.put("businessTerms", primary.businessTerms); + if (primary.rationale != null) payload.put("rationale", primary.rationale); + + if (bucket.size() > 1) { + List> alternates = new ArrayList<>(); + for (int i = 1; i < bucket.size(); i++) { + var alt = bucket.get(i); + Map a = new LinkedHashMap<>(); + a.put("title", alt.title); + a.put("content", alt.content); + a.put("confidence", alt.confidence); + a.put("rationale", alt.rationale); + a.put("sourcePath", alt.sourcePath); + a.put("sourceStartLine", alt.sourceStartLine); + a.put("sourceEndLine", alt.sourceEndLine); + alternates.add(a); } + payload.put("alternatives", alternates); } - } - - primary.setPayload(payload); - primary.setSourceFiles(mergeSourceFiles(primary.getSourceFiles(), other.getSourceFiles())); - mergeExistingContextMarkers(payload, other.getPayload()); - return primary; - } - - private static void appendAlternate(Map payload, CodeKnowledgeSuggestion other) { - Map alt = new LinkedHashMap<>(); - alt.put("title", other.getTitle()); - alt.put("content", other.getContent()); - alt.put("confidence", other.getConfidence()); - if (other.getPayload() != null && other.getPayload().get("rationale") != null) { - alt.put("rationale", other.getPayload().get("rationale")); - } - appendAlternateMap(payload, alt); - } - - @SuppressWarnings("unchecked") - private static void appendAlternateMap(Map payload, Map alt) { - List> alternates = (List>) payload.computeIfAbsent( - "alternatives", - k -> new ArrayList<>() - ); - alternates.add(alt); - } - - private static void mergeExistingContextMarkers(Map target, Map source) { - if (source == null) return; - if (Boolean.TRUE.equals(source.get("mergedWithExisting"))) { - target.put("mergedWithExisting", true); - } - for (String key : List.of("existingEntryId", "existingDocId", "existingTitle", "existingExcerpt")) { - if (source.get(key) != null && target.get(key) == null) { - target.put(key, source.get(key)); - } - } - } - - private static List> buildAlternates(List bucket) { - List> alternates = new ArrayList<>(); - for (var alt : bucket) { - Map a = new LinkedHashMap<>(); - a.put("title", alt.title); - a.put("content", alt.content); - a.put("confidence", alt.confidence); - a.put("rationale", alt.rationale); - a.put("sourcePath", alt.sourcePath); - a.put("sourceStartLine", alt.sourceStartLine); - a.put("sourceEndLine", alt.sourceEndLine); - alternates.add(a); - } - return alternates; - } - - private static List> buildSourceFiles(List bucket) { - List> sources = new ArrayList<>(); - Set seenSources = new HashSet<>(); - for (var item : bucket) { - String key = item.sourcePath + ":" + item.sourceStartLine + ":" + item.sourceEndLine; - if (!seenSources.add(key)) continue; - Map sf = new LinkedHashMap<>(); - sf.put("path", item.sourcePath); - sf.put("startLine", item.sourceStartLine); - sf.put("endLine", item.sourceEndLine); - if (item.rationale != null) sf.put("rationale", item.rationale); - sources.add(sf); - } - return sources; - } + suggestion.setPayload(payload); - @SuppressWarnings("unchecked") - private static List> mergeSourceFiles( - List> left, - List> right - ) { - List> merged = new ArrayList<>(); - Set seen = new HashSet<>(); - for (List> list : List.of(left, right)) { - if (list == null) continue; - for (Map sf : list) { - String key = sf.get("path") + ":" + sf.get("startLine") + ":" + sf.get("endLine"); - if (seen.add(key)) merged.add(sf); + // Source files: primary chunk + dedup of alt chunks + List> sources = new ArrayList<>(); + Set seenSources = new HashSet<>(); + for (var item : bucket) { + String key = item.sourcePath + ":" + item.sourceStartLine + ":" + item.sourceEndLine; + if (!seenSources.add(key)) continue; + Map sf = new LinkedHashMap<>(); + sf.put("path", item.sourcePath); + sf.put("startLine", item.sourceStartLine); + sf.put("endLine", item.sourceEndLine); + if (item.rationale != null) sf.put("rationale", item.rationale); + sources.add(sf); } - } - return merged; - } + suggestion.setSourceFiles(sources); - private static Map payloadOrNew(CodeKnowledgeSuggestion suggestion) { - Map payload = suggestion.getPayload(); - if (payload == null) { - payload = new LinkedHashMap<>(); - suggestion.setPayload(payload); + out.add(suggestion); } - return payload; + return out; } private static String groupKey(CodeKnowledgeExtractor.RawSuggestion s) { @@ -248,7 +129,8 @@ private static String groupKey(CodeKnowledgeExtractor.RawSuggestion s) { String table = s.targetTable == null ? "" : s.targetTable.toLowerCase(Locale.ROOT); String col = s.targetColumn == null ? "" : s.targetColumn.toLowerCase(Locale.ROOT); if ("KNOWLEDGE_ENTRY".equals(kind) && col.isEmpty() && table.isEmpty() && s.title != null) { - return kind + "|TITLE|" + normalizeText(s.title); + // narrative entries with no schema target — group by title to dedupe near-duplicates + return kind + "|TITLE|" + s.title.toLowerCase(Locale.ROOT); } return kind + "|" + table + "|" + col; } @@ -288,6 +170,7 @@ private static Map buildTableLookup(SchemaMetadata schema) { return lookup; } + /** Maps lowercase "table.column" → canonical "table.column" string from the schema. */ private static Map buildColumnLookup(SchemaMetadata schema) { Map lookup = new HashMap<>(); if (schema == null || schema.getTables() == null) return lookup; @@ -301,195 +184,4 @@ private static Map buildColumnLookup(SchemaMetadata schema) { } return lookup; } - - private static String normalizeText(String value) { - if (value == null) return ""; - return value.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", " "); - } - - /** - * Indexes live knowledge-base rows so new scan proposals can be folded into a - * single review intent when they overlap an existing rule or schema note. - */ - static final class ExistingContextIndex { - private final Map knowledgeByTitle = new HashMap<>(); - private final Map knowledgeByLink = new HashMap<>(); - private final Map docsByTarget = new HashMap<>(); - - ExistingContextIndex(List knowledge, List docs) { - if (knowledge != null) { - for (CompanyKnowledgeEntry entry : knowledge) { - if (entry == null || entry.getId() == null) continue; - if (entry.getTitle() != null) { - knowledgeByTitle.put(normalizeText(entry.getTitle()), entry); - } - indexKnowledgeLinks(entry); - } - } - if (docs != null) { - for (SchemaDocumentation doc : docs) { - if (doc == null || doc.getId() == null) continue; - String key = schemaDocTargetKey(doc); - if (!key.isBlank()) { - docsByTarget.put(key, doc); - } - } - } - } - - private void indexKnowledgeLinks(CompanyKnowledgeEntry entry) { - String entryType = entry.getEntryType() == null - ? "business_rule" - : entry.getEntryType().name().toLowerCase(Locale.ROOT); - if (entry.getLinkedColumns() != null) { - for (String col : entry.getLinkedColumns()) { - if (col == null || col.isBlank()) continue; - knowledgeByLink.put("col|" + entryType + "|" + normalizeText(col), entry); - } - } - if (entry.getLinkedTables() != null) { - for (String table : entry.getLinkedTables()) { - if (table == null || table.isBlank()) continue; - knowledgeByLink.put("table|" + entryType + "|" + normalizeText(table), entry); - } - } - } - - String intentKeyFor(CodeKnowledgeSuggestion suggestion) { - ExistingMatch match = findMatch(suggestion); - if (match != null) { - annotateExisting(suggestion, match); - return match.intentKey(); - } - return internalIntentKey(suggestion); - } - - private ExistingMatch findMatch(CodeKnowledgeSuggestion suggestion) { - if (suggestion.getTargetKind() == CodeKnowledgeSuggestion.TargetKind.SCHEMA_DOC) { - String key = normalizeSchemaTarget(suggestion.getTargetObject()); - SchemaDocumentation doc = docsByTarget.get(key); - if (doc != null) { - return ExistingMatch.forDoc(doc); - } - return null; - } - - CompanyKnowledgeEntry byTitle = suggestion.getTitle() == null - ? null - : knowledgeByTitle.get(normalizeText(suggestion.getTitle())); - if (byTitle != null) { - return ExistingMatch.forEntry(byTitle); - } - - String entryType = resolveEntryTypeKey(suggestion); - if (suggestion.getLinkedColumns() != null) { - for (String col : suggestion.getLinkedColumns()) { - CompanyKnowledgeEntry hit = knowledgeByLink.get("col|" + entryType + "|" + normalizeText(col)); - if (hit != null) return ExistingMatch.forEntry(hit); - } - } - if (suggestion.getLinkedTables() != null) { - for (String table : suggestion.getLinkedTables()) { - CompanyKnowledgeEntry hit = knowledgeByLink.get("table|" + entryType + "|" + normalizeText(table)); - if (hit != null) return ExistingMatch.forEntry(hit); - } - } - if (suggestion.getTargetObject() != null) { - CompanyKnowledgeEntry hit = knowledgeByLink.get( - "table|" + entryType + "|" + normalizeText(suggestion.getTargetObject()) - ); - if (hit != null) return ExistingMatch.forEntry(hit); - } - return null; - } - - private static void annotateExisting(CodeKnowledgeSuggestion suggestion, ExistingMatch match) { - Map payload = suggestion.getPayload(); - if (payload == null) { - payload = new LinkedHashMap<>(); - suggestion.setPayload(payload); - } - payload.put("mergedWithExisting", true); - if (match.entry() != null) { - payload.put("existingEntryId", match.entry().getId()); - payload.put("existingTitle", match.entry().getTitle()); - payload.put("existingExcerpt", excerpt(match.entry().getContent())); - } - if (match.doc() != null) { - payload.put("existingDocId", match.doc().getId()); - payload.put("existingTitle", schemaDocLabel(match.doc())); - payload.put("existingExcerpt", excerpt(match.doc().getDescription())); - } - } - - private static String internalIntentKey(CodeKnowledgeSuggestion suggestion) { - String kind = suggestion.getTargetKind() == null ? "KNOWLEDGE_ENTRY" : suggestion.getTargetKind().name(); - String target = suggestion.getTargetObject() == null ? "" : normalizeText(suggestion.getTargetObject()); - String title = suggestion.getTitle() == null ? "" : normalizeText(suggestion.getTitle()); - return "NEW|" + kind + "|" + target + "|" + title; - } - - private static String resolveEntryTypeKey(CodeKnowledgeSuggestion suggestion) { - Object raw = suggestion.getPayload() == null ? null : suggestion.getPayload().get("entryType"); - if (raw == null) return "business_rule"; - return raw.toString().trim().toLowerCase(Locale.ROOT); - } - - private static String schemaDocTargetKey(SchemaDocumentation doc) { - if (doc.getObjectType() == SchemaDocumentation.DocumentationType.COLUMN) { - String parent = bareName(doc.getParentObject()); - return normalizeSchemaTarget(parent + "." + doc.getObjectName()); - } - return normalizeSchemaTarget(doc.getObjectName()); - } - - private static String schemaDocLabel(SchemaDocumentation doc) { - if (doc.getObjectType() == SchemaDocumentation.DocumentationType.COLUMN) { - return bareName(doc.getParentObject()) + "." + doc.getObjectName(); - } - return doc.getObjectName(); - } - - private static String bareName(String value) { - if (value == null) return ""; - int dot = value.lastIndexOf('.'); - return dot >= 0 ? value.substring(dot + 1) : value; - } - - private static String normalizeSchemaTarget(String target) { - if (target == null) return ""; - String trimmed = target.trim().toLowerCase(Locale.ROOT); - int dot = trimmed.lastIndexOf('.'); - if (dot < 0) return trimmed; - String left = trimmed.substring(0, dot); - String right = trimmed.substring(dot + 1); - int leftDot = left.lastIndexOf('.'); - if (leftDot >= 0) { - left = left.substring(leftDot + 1); - } - return left + "." + right; - } - - private static String excerpt(String text) { - if (text == null) return ""; - String trimmed = text.trim(); - if (trimmed.length() <= 480) return trimmed; - return trimmed.substring(0, 477) + "…"; - } - } - - private record ExistingMatch(CompanyKnowledgeEntry entry, SchemaDocumentation doc) { - static ExistingMatch forEntry(CompanyKnowledgeEntry entry) { - return new ExistingMatch(entry, null); - } - - static ExistingMatch forDoc(SchemaDocumentation doc) { - return new ExistingMatch(null, doc); - } - - String intentKey() { - if (entry != null) return "EXISTING_ENTRY|" + entry.getId(); - return "EXISTING_DOC|" + doc.getId(); - } - } } diff --git a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java index a1ef661..174bf64 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java @@ -5,7 +5,6 @@ import com.dbaagent.model.SchemaDocumentation; import com.dbaagent.model.code.CodeKnowledgeSuggestion; import com.dbaagent.repository.CodeKnowledgeSuggestionRepository; -import com.dbaagent.repository.CompanyKnowledgeEntryRepository; import com.dbaagent.repository.SchemaDocumentationRepository; import com.dbaagent.service.CompanyKnowledgeService; import com.dbaagent.service.SchemaScannerService; @@ -16,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -34,7 +32,6 @@ public class CodeSuggestionApplier { private final CodeKnowledgeSuggestionRepository suggestionRepository; private final SchemaDocumentationRepository schemaDocRepository; - private final CompanyKnowledgeEntryRepository companyKnowledgeEntryRepository; private final CompanyKnowledgeService companyKnowledgeService; private final TrainingService trainingService; private final SchemaScannerService schemaScannerService; @@ -81,36 +78,6 @@ public CodeKnowledgeSuggestion reject(String suggestionId, String decidedBy, Str } private void applySchemaDoc(CodeKnowledgeSuggestion suggestion, String decidedBy) { - Map payload = suggestion.getPayload(); - Object existingDocId = payload == null ? null : payload.get("existingDocId"); - if (existingDocId != null && !existingDocId.toString().isBlank()) { - SchemaDocumentation doc = schemaDocRepository.findById(existingDocId.toString()) - .orElseThrow(() -> new IllegalArgumentException( - "Existing schema doc not found: " + existingDocId - )); - doc.setDescription(mergeText(doc.getDescription(), suggestion.getContent())); - Object terms = payload.get("businessTerms"); - if (terms instanceof List list && !list.isEmpty()) { - String mergedTerms = mergeCommaSeparated(doc.getBusinessTerms(), list); - doc.setBusinessTerms(mergedTerms); - } - if (doc.getConfidence() == null || (suggestion.getConfidence() != null - && suggestion.getConfidence() > doc.getConfidence())) { - doc.setConfidence(suggestion.getConfidence()); - } - if (suggestion.getSourceFiles() != null && !suggestion.getSourceFiles().isEmpty()) { - doc.setSourceFiles(suggestion.getSourceFiles()); - } - SchemaDocumentation saved = schemaDocRepository.save(doc); - suggestion.setAppliedDocId(saved.getId()); - try { - trainingService.upsertDocumentationEmbedding(saved); - } catch (Exception e) { - log.warn("Failed to embed schema doc {} after approval: {}", saved.getId(), e.getMessage()); - } - return; - } - SchemaDocumentation.DocumentationType objectType = resolveObjectType(suggestion); String objectName; String parentObject = null; @@ -199,27 +166,6 @@ private void applySchemaDoc(CodeKnowledgeSuggestion suggestion, String decidedBy } private void applyKnowledgeEntry(CodeKnowledgeSuggestion suggestion, String decidedBy) { - Map payload = suggestion.getPayload(); - Object existingId = payload == null ? null : payload.get("existingEntryId"); - if (existingId != null && !existingId.toString().isBlank()) { - CompanyKnowledgeEntry existing = companyKnowledgeEntryRepository.findById(existingId.toString()) - .orElseThrow(() -> new IllegalArgumentException( - "Existing company knowledge entry not found: " + existingId - )); - CompanyKnowledgeEntry merged = CompanyKnowledgeEntry.builder() - .connectionId(existing.getConnectionId()) - .title(pickTitle(existing.getTitle(), suggestion.getTitle())) - .content(mergeText(existing.getContent(), suggestion.getContent())) - .entryType(resolveEntryType(suggestion)) - .linkedTables(mergeStringLists(existing.getLinkedTables(), suggestion.getLinkedTables())) - .linkedColumns(mergeStringLists(existing.getLinkedColumns(), suggestion.getLinkedColumns())) - .createdBy(decidedBy) - .build(); - CompanyKnowledgeEntry saved = companyKnowledgeService.updateEntry(existing.getId(), merged); - suggestion.setAppliedEntryId(saved.getId()); - return; - } - CompanyKnowledgeEntry entry = CompanyKnowledgeEntry.builder() .connectionId(suggestion.getConnectionId()) .title(suggestion.getTitle()) @@ -274,53 +220,4 @@ private static CompanyKnowledgeEntry.EntryType resolveEntryType(CodeKnowledgeSug } return CompanyKnowledgeEntry.EntryType.BUSINESS_RULE; } - - private static String pickTitle(String existing, String proposed) { - if (proposed == null || proposed.isBlank()) return existing; - if (existing == null || existing.isBlank()) return proposed; - return proposed.length() >= existing.length() ? proposed : existing; - } - - private static String mergeText(String existing, String addition) { - if (addition == null || addition.isBlank()) { - return existing == null ? "" : existing; - } - if (existing == null || existing.isBlank()) { - return addition.trim(); - } - String left = existing.trim(); - String right = addition.trim(); - if (left.equalsIgnoreCase(right)) return left; - if (left.toLowerCase().contains(right.toLowerCase())) return left; - if (right.toLowerCase().contains(left.toLowerCase())) return right; - return left + "\n\n" + right; - } - - private static List mergeStringLists(List left, List right) { - List merged = new ArrayList<>(); - if (left != null) merged.addAll(left); - if (right != null) { - for (String value : right) { - if (value != null && !value.isBlank() && !merged.contains(value)) { - merged.add(value); - } - } - } - return merged; - } - - private static String mergeCommaSeparated(String existing, List additions) { - List merged = new ArrayList<>(); - if (existing != null && !existing.isBlank()) { - for (String part : existing.split(",")) { - String trimmed = part.trim(); - if (!trimmed.isEmpty() && !merged.contains(trimmed)) merged.add(trimmed); - } - } - for (Object item : additions) { - String value = String.valueOf(item).trim(); - if (!value.isEmpty() && !merged.contains(value)) merged.add(value); - } - return String.join(", ", merged); - } } diff --git a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionAggregatorTest.java b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionAggregatorTest.java index 15bda8d..8cd2964 100644 --- a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionAggregatorTest.java +++ b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionAggregatorTest.java @@ -1,8 +1,6 @@ package com.dbaagent.service.codescan; import com.dbaagent.model.ColumnMetadata; -import com.dbaagent.model.CompanyKnowledgeEntry; -import com.dbaagent.model.SchemaDocumentation; import com.dbaagent.model.SchemaMetadata; import com.dbaagent.model.TableMetadata; import com.dbaagent.model.code.CodeKnowledgeSuggestion; @@ -91,69 +89,6 @@ void dropsColumnSuggestionWhenColumnUnknown() { assertTrue(aggregator.aggregate("c1", "j1", List.of(raw), schemaWithBookings()).isEmpty()); } - @Test - void mergesWithExistingSchemaDocIntent() { - SchemaDocumentation existing = SchemaDocumentation.builder() - .id("doc-1") - .connectionId("c1") - .objectType(SchemaDocumentation.DocumentationType.COLUMN) - .objectName("status") - .parentObject("analytics_db.bookings") - .description("Existing lifecycle note.") - .build(); - - var raw = new CodeKnowledgeExtractor.RawSuggestion(); - raw.kind = "SCHEMA_DOC"; - raw.targetTable = "bookings"; - raw.targetColumn = "status"; - raw.title = "Booking status"; - raw.content = "Reservation state machine from code."; - raw.confidence = 0.82; - - var out = aggregator.aggregate( - "c1", - "j1", - List.of(raw), - schemaWithBookings(), - List.of(), - List.of(existing) - ); - assertEquals(1, out.size()); - assertTrue(Boolean.TRUE.equals(out.get(0).getPayload().get("mergedWithExisting"))); - assertEquals("doc-1", out.get(0).getPayload().get("existingDocId")); - assertEquals("Existing lifecycle note.", out.get(0).getPayload().get("existingExcerpt")); - } - - @Test - void mergesWithExistingKnowledgeEntryByTitle() { - CompanyKnowledgeEntry existing = CompanyKnowledgeEntry.builder() - .id("entry-1") - .connectionId("c1") - .title("Refund policy") - .entryType(CompanyKnowledgeEntry.EntryType.BUSINESS_RULE) - .content("Refunds are allowed within 30 days.") - .build(); - - var raw = new CodeKnowledgeExtractor.RawSuggestion(); - raw.kind = "KNOWLEDGE_ENTRY"; - raw.title = "Refund policy"; - raw.content = "Partial refunds require manager approval."; - raw.confidence = 0.77; - raw.entryType = "BUSINESS_RULE"; - - var out = aggregator.aggregate( - "c1", - "j1", - List.of(raw), - schemaWithBookings(), - List.of(existing), - List.of() - ); - assertEquals(1, out.size()); - assertTrue(Boolean.TRUE.equals(out.get(0).getPayload().get("mergedWithExisting"))); - assertEquals("entry-1", out.get(0).getPayload().get("existingEntryId")); - } - private static ColumnMetadata col(String name, String dataType) { ColumnMetadata c = new ColumnMetadata(); c.setName(name); diff --git a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java index 7aa2bfd..773806f 100644 --- a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java +++ b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java @@ -1,12 +1,10 @@ package com.dbaagent.service.codescan; -import com.dbaagent.model.CompanyKnowledgeEntry; import com.dbaagent.model.DocumentationSource; import com.dbaagent.model.SchemaDocumentation; import com.dbaagent.model.SchemaMetadata; import com.dbaagent.model.code.CodeKnowledgeSuggestion; import com.dbaagent.repository.CodeKnowledgeSuggestionRepository; -import com.dbaagent.repository.CompanyKnowledgeEntryRepository; import com.dbaagent.repository.SchemaDocumentationRepository; import com.dbaagent.service.CompanyKnowledgeService; import com.dbaagent.service.SchemaScannerService; @@ -35,7 +33,6 @@ class CodeSuggestionApplierTest { @Mock private CodeKnowledgeSuggestionRepository suggestionRepository; @Mock private SchemaDocumentationRepository schemaDocRepository; - @Mock private CompanyKnowledgeEntryRepository companyKnowledgeEntryRepository; @Mock private CompanyKnowledgeService companyKnowledgeService; @Mock private TrainingService trainingService; @Mock private SchemaScannerService schemaScannerService; @@ -47,7 +44,6 @@ void setUp() { applier = new CodeSuggestionApplier( suggestionRepository, schemaDocRepository, - companyKnowledgeEntryRepository, companyKnowledgeService, trainingService, schemaScannerService diff --git a/src/components/AgentChat/AgentChatPanel.jsx b/src/components/AgentChat/AgentChatPanel.jsx index 840c2ab..0326c6b 100644 --- a/src/components/AgentChat/AgentChatPanel.jsx +++ b/src/components/AgentChat/AgentChatPanel.jsx @@ -1,8 +1,7 @@ import { useState, useRef, useEffect, useCallback } from 'react' -import { ArrowUp, Plus, Square, Loader2, Database, Sparkles, Hash, Table2, Clock, AlertCircle, Inbox } from 'lucide-react' +import { ArrowUp, Plus, Square, Loader2, Database, Sparkles, Hash, Table2, Clock, AlertCircle } from 'lucide-react' import { agentChatAPI, withConnectionContext } from '@/lib/api/agentClient' import { agentConversationAPI } from '@/lib/api/client' -import { useCodeScanSuggestions } from '@/lib/hooks/queries' import { useAuth } from '@/hooks/useAuth' import AgentMarkdown from './AgentMarkdown' import { sanitizeAssistantAnswer } from './sanitizeAssistantAnswer' @@ -63,14 +62,6 @@ export default function AgentChatPanel({ connectionId, connectionName }) { const convIdRef = useRef(null) // backend conversation id (the per-user index row) const restoredRef = useRef(false) // guards the persist effect until boot finishes - const pendingSuggestionsQuery = useCodeScanSuggestions({ - connectionId, - status: 'PENDING', - page: 0, - size: 1, - }) - const pendingSuggestionCount = pendingSuggestionsQuery.data?.totalElements ?? 0 - const boot = useCallback(async ({ fresh = false } = {}) => { setBooting(true); setBootError(null); setAuthBlocked(false) restoredRef.current = false @@ -207,16 +198,6 @@ export default function AgentChatPanel({ connectionId, connectionName }) {
- {pendingSuggestionCount > 0 && ( -
-
- )} -
{booting && (
diff --git a/src/components/AgentChat/AgentChatPanel.module.css b/src/components/AgentChat/AgentChatPanel.module.css index 0a21e2b..dd758c4 100644 --- a/src/components/AgentChat/AgentChatPanel.module.css +++ b/src/components/AgentChat/AgentChatPanel.module.css @@ -33,20 +33,6 @@ } .connBadge svg { flex-shrink: 0; color: #534AB7; } -.reviewHint { - display: flex; - align-items: center; - gap: 8px; - margin: 0; - padding: 8px 20px; - font-size: 12.5px; - line-height: 1.45; - color: #5b4b1f; - background: #fffbeb; - border-bottom: 1px solid #fde68a; -} -.reviewHint svg { flex-shrink: 0; color: #b45309; } - .newChat { display: inline-flex; align-items: center; gap: 5px; height: 30px; diff --git a/src/components/company-knowledge/CompanyKnowledgePanel.jsx b/src/components/company-knowledge/CompanyKnowledgePanel.jsx index 1c323e4..d031f93 100644 --- a/src/components/company-knowledge/CompanyKnowledgePanel.jsx +++ b/src/components/company-knowledge/CompanyKnowledgePanel.jsx @@ -600,9 +600,7 @@ export default function CompanyKnowledgePanel({ connectionId }) { )} - {pendingSuggestionCount > 0 - ? `${pendingSuggestionCount} optional — review anytime` - : 'Accept what fits'} + {pendingSuggestionCount > 0 ? `${pendingSuggestionCount} awaiting sign-off` : 'Accept what fits'} @@ -625,7 +623,7 @@ export default function CompanyKnowledgePanel({ connectionId }) {

- Review is optional and never blocks Agent chat — approve suggestions when you want them in the knowledge base. + Knowledge flows left to right — nothing reaches the knowledge base until you review and accept it.

{activeTab === 'schema-context' && } diff --git a/src/components/company-knowledge/CompanyKnowledgePanel.module.css b/src/components/company-knowledge/CompanyKnowledgePanel.module.css index de3d667..10fca0e 100644 --- a/src/components/company-knowledge/CompanyKnowledgePanel.module.css +++ b/src/components/company-knowledge/CompanyKnowledgePanel.module.css @@ -668,200 +668,7 @@ background: #fff; } -/* ===== Suggestions bubble layout ===== */ - -.suggestionsBubblePanel { - display: flex; - flex-direction: column; - gap: 12px; - padding: 14px; - border: 1px solid #e5e7eb; - border-radius: 16px; - background: #fff; - min-height: 200px; -} - -.suggestionsBubbleToolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.bubbleSelectAll { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 12px; - font-weight: 600; - color: #6b7280; -} - -.suggestionBubbles { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-content: flex-start; -} - -.suggestionBubbleWrap { - display: flex; - align-items: flex-start; - gap: 6px; - max-width: 100%; -} - -.suggestionBubbleCheckbox { - display: flex; - align-items: center; - padding-top: 12px; -} - -.suggestionBubble { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; - min-width: 180px; - max-width: 280px; - padding: 12px 14px; - border: 1px solid #e5e7eb; - border-radius: 999px; - background: #f9fafb; - color: #111827; - text-align: left; - cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; -} - -.suggestionBubble:hover { - background: #fff; - border-color: #c7d2fe; - transform: translateY(-1px); -} - -.suggestionBubbleActive { - background: #eef2ff; - border-color: #6366f1; - box-shadow: 0 8px 24px rgba(99, 102, 241, 0.12); -} - -.suggestionBubbleSelected { - outline: 2px solid #93c5fd; - outline-offset: 2px; -} - -.suggestionBubbleTop { - display: flex; - flex-wrap: wrap; - gap: 6px; - width: 100%; -} - -.suggestionBubbleTitle { - font-size: 13px; - font-weight: 700; - line-height: 1.35; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.suggestionBubbleTarget { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 11px; - color: #6b7280; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - -.suggestionBubbleMerge { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - color: #92400e; - background: #fef3c7; - padding: 2px 8px; - border-radius: 999px; -} - -.mergeBadge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 999px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - background: #fef3c7; - color: #92400e; -} - -.previewSectionExisting { - padding: 12px; - border-radius: 12px; - background: #fffbeb; - border: 1px solid #fde68a; -} - -.previewSectionExcerpt { - padding: 0; -} - -.previewExistingTitle { - font-size: 13px; - font-weight: 700; - color: #92400e; - margin-bottom: 6px; -} - -.previewBodyMuted { - margin: 0; - font-size: 13px; - line-height: 1.55; - color: #78350f; -} - -.previewMergeHint { - margin: 8px 0 0; - font-size: 12px; - color: #92400e; -} - -.ruleExcerptCard { - padding: 14px; - border-radius: 14px; - border: 1px solid #dbeafe; - background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%); -} - -.ruleExcerptHeadline { - font-size: 14px; - font-weight: 700; - color: #111827; - margin-bottom: 8px; -} - -.ruleExcerptBody { - margin: 0; - font-size: 13px; - line-height: 1.6; - color: #374151; - white-space: pre-wrap; -} - -.ruleExcerptMeta { - margin-top: 10px; - font-size: 12px; - color: #6b7280; -} - -/* ===== Suggestions table layout (legacy rows kept for reference) ===== */ +/* ===== Suggestions table layout ===== */ .suggestionsLayout { display: grid; diff --git a/src/components/company-knowledge/SuggestionsQueueTab.jsx b/src/components/company-knowledge/SuggestionsQueueTab.jsx index 9e789db..a15ef6d 100644 --- a/src/components/company-knowledge/SuggestionsQueueTab.jsx +++ b/src/components/company-knowledge/SuggestionsQueueTab.jsx @@ -48,34 +48,12 @@ function buildSearchHaystack(s) { return parts.join(' ').toLowerCase() } -function buildRuleExcerpt(suggestion) { - if (!suggestion) return null - const entryType = suggestion.payload?.entryType || 'BUSINESS_RULE' - if (suggestion.targetKind === 'SCHEMA_DOC') { - const target = suggestion.targetObject || 'schema object' - return { - label: 'Schema note that would be saved', - headline: target, - body: suggestion.content, - meta: suggestion.payload?.businessTerms?.length - ? `Terms: ${suggestion.payload.businessTerms.join(', ')}` - : null, - } - } - return { - label: `${String(entryType).replaceAll('_', ' ').toLowerCase()} that would be saved`, - headline: suggestion.title, - body: suggestion.content, - meta: suggestion.targetObject ? `Linked: ${suggestion.targetObject}` : null, - } -} - function PreviewPane({ suggestion, pinned, onPin, onUnpin, onDecide, decidePending }) { if (!suggestion) { return ( ) @@ -85,10 +63,6 @@ function PreviewPane({ suggestion, pinned, onPin, onUnpin, onDecide, decidePendi const sources = suggestion.sourceFiles || [] const businessTerms = suggestion.payload?.businessTerms || [] const rationale = suggestion.payload?.rationale - const excerpt = buildRuleExcerpt(suggestion) - const mergedWithExisting = Boolean(suggestion.payload?.mergedWithExisting) - const existingExcerpt = suggestion.payload?.existingExcerpt - const existingTitle = suggestion.payload?.existingTitle return ( ) } @@ -231,7 +180,7 @@ export default function SuggestionsQueueTab({ connectionId }) { const [search, setSearch] = useState('') const [minConfidence, setMinConfidence] = useState(0) const [selected, setSelected] = useState(() => new Set()) - const [activeId, setActiveId] = useState(null) + const [hoveredId, setHoveredId] = useState(null) const [pinnedId, setPinnedId] = useState(null) const [bulkError, setBulkError] = useState(null) const [bulkSuccess, setBulkSuccess] = useState(null) @@ -269,7 +218,7 @@ export default function SuggestionsQueueTab({ connectionId }) { const allVisibleSelected = filtered.length > 0 && filtered.every((s) => selected.has(s.id)) const someVisibleSelected = !allVisibleSelected && filtered.some((s) => selected.has(s.id)) - const focusedId = pinnedId || activeId || filtered[0]?.id || null + const focusedId = pinnedId || hoveredId const focused = useMemo( () => indexed.find((s) => s.id === focusedId) || null, [indexed, focusedId], @@ -470,9 +419,9 @@ export default function SuggestionsQueueTab({ connectionId }) {
) : (
-
-
-