adeshboudh16 Claude Sonnet 4.6 commited on
Commit ·
d91cbff
1
Parent(s): 8061c46
feat: decouple frontend + multi-turn conversation + graph explorer + CAG layer
Browse files- Next.js frontend (chat UI, graph explorer, D3 force graph, citations panel)
- Multi-turn conversation with LangGraph PostgresSaver checkpointing
- Graph API routes (topology, section content, section-context query)
- Retrieval caching layer (CAG) with TTL-based embedding + graph caches
- LLM fallback chain: gemini-2.5-flash-lite → groq → openrouter
- Updated .gitignore and Makefile for cross-platform frontend builds
- frontend/.env.production for Vercel deployment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +20 -0
- Makefile +21 -2
- README.md +1 -0
- frontend/.env.production +1 -0
- frontend/next-env.d.ts +6 -0
- frontend/next.config.ts +14 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +33 -0
- frontend/postcss.config.js +6 -0
- frontend/src/app/globals.css +86 -0
- frontend/src/app/layout.tsx +43 -0
- frontend/src/app/page.tsx +132 -0
- frontend/src/components/ChatThread.tsx +73 -0
- frontend/src/components/CitationsPanel.tsx +64 -0
- frontend/src/components/ConfidenceBadge.tsx +31 -0
- frontend/src/components/Header.tsx +83 -0
- frontend/src/components/InputBar.tsx +171 -0
- frontend/src/components/MessageBubble.tsx +109 -0
- frontend/src/components/ThemeProvider.tsx +8 -0
- frontend/src/components/graph/ContextPill.tsx +26 -0
- frontend/src/components/graph/ForceGraph.tsx +274 -0
- frontend/src/components/graph/GraphExplorer.tsx +132 -0
- frontend/src/components/graph/GraphFilterSidebar.tsx +155 -0
- frontend/src/components/graph/NodeInfoCard.tsx +112 -0
- frontend/src/components/graph/SectionDrawer.tsx +133 -0
- frontend/src/hooks/useChat.ts +99 -0
- frontend/src/hooks/useGraphExplorer.ts +102 -0
- frontend/src/lib/api.ts +92 -0
- frontend/src/lib/constants.ts +44 -0
- frontend/src/lib/types.ts +122 -0
- frontend/tailwind.config.ts +31 -0
- frontend/tsconfig.json +27 -0
- frontend/tsconfig.tsbuildinfo +0 -0
- pyproject.toml +2 -0
- src/civicsetu/agent/graph.py +21 -21
- src/civicsetu/agent/nodes.py +144 -42
- src/civicsetu/agent/state.py +6 -3
- src/civicsetu/api/main.py +50 -450
- src/civicsetu/api/routes/graph.py +261 -0
- src/civicsetu/api/routes/query.py +40 -15
- src/civicsetu/config/settings.py +27 -2
- src/civicsetu/guardrails/output_guard.py +9 -2
- src/civicsetu/models/schemas.py +156 -93
- src/civicsetu/prompts/generator.py +6 -3
- src/civicsetu/retrieval/__init__.py +28 -0
- src/civicsetu/retrieval/cache.py +15 -0
- src/civicsetu/retrieval/graph_retriever.py +16 -1
- src/civicsetu/stores/graph_store.py +33 -0
- src/civicsetu/stores/vector_store.py +13 -0
- tests/conftest.py +2 -1
.gitignore
CHANGED
|
@@ -28,3 +28,23 @@ __MACOSX/
|
|
| 28 |
.cache/
|
| 29 |
models/
|
| 30 |
**/*.onnx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
.cache/
|
| 29 |
models/
|
| 30 |
**/*.onnx
|
| 31 |
+
|
| 32 |
+
/docs/superpowers/**
|
| 33 |
+
CLAUDE.md
|
| 34 |
+
.claude/
|
| 35 |
+
|
| 36 |
+
# Design artifacts
|
| 37 |
+
stitch/
|
| 38 |
+
stitch.zip
|
| 39 |
+
index.html
|
| 40 |
+
|
| 41 |
+
# Frontend (Next.js)
|
| 42 |
+
frontend/.env.local
|
| 43 |
+
frontend/.env*.local
|
| 44 |
+
frontend/.next/
|
| 45 |
+
frontend/out/
|
| 46 |
+
frontend/node_modules/
|
| 47 |
+
frontend/.vercel/
|
| 48 |
+
frontend/npm-debug.log*
|
| 49 |
+
frontend/yarn-debug.log*
|
| 50 |
+
frontend/yarn-error.log*
|
Makefile
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
.PHONY: help install dev serve ingest lint format typecheck test test-cov e2e docker-up docker-down clean
|
| 2 |
|
| 3 |
help:
|
| 4 |
@echo "CivicSetu — available commands:"
|
|
@@ -71,4 +71,23 @@ docker-down:
|
|
| 71 |
clean:
|
| 72 |
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
| 73 |
find . -name "*.pyc" -delete 2>/dev/null || true
|
| 74 |
-
find . -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: help install dev serve ingest lint format typecheck test test-cov e2e docker-up docker-down clean frontend-install frontend-dev frontend-build frontend-start frontend-lint frontend-typecheck
|
| 2 |
|
| 3 |
help:
|
| 4 |
@echo "CivicSetu — available commands:"
|
|
|
|
| 71 |
clean:
|
| 72 |
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
| 73 |
find . -name "*.pyc" -delete 2>/dev/null || true
|
| 74 |
+
find . -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
|
| 75 |
+
|
| 76 |
+
# Frontend
|
| 77 |
+
frontend-install:
|
| 78 |
+
cd frontend && npm install
|
| 79 |
+
|
| 80 |
+
frontend-dev:
|
| 81 |
+
cd frontend && npm run dev
|
| 82 |
+
|
| 83 |
+
frontend-build:
|
| 84 |
+
cd frontend && npm run build
|
| 85 |
+
|
| 86 |
+
frontend-start:
|
| 87 |
+
cd frontend && npm run start
|
| 88 |
+
|
| 89 |
+
frontend-lint:
|
| 90 |
+
cd frontend && npm run lint
|
| 91 |
+
|
| 92 |
+
frontend-typecheck:
|
| 93 |
+
cd frontend && npm run typecheck
|
README.md
CHANGED
|
@@ -134,6 +134,7 @@ make docker-down # Tear down containers
|
|
| 134 |
| RERA Act 2016 | Central | 224 |
|
| 135 |
| Maharashtra Real Estate Rules 2017 | Maharashtra | 214 |
|
| 136 |
| UP RERA Rules 2016 | Uttar Pradesh | 170 |
|
|
|
|
| 137 |
| Karnataka RERA Rules 2017 | Karnataka | 235 |
|
| 138 |
| Tamil Nadu RERA Rules 2017 | Tamil Nadu | 157 |
|
| 139 |
|
|
|
|
| 134 |
| RERA Act 2016 | Central | 224 |
|
| 135 |
| Maharashtra Real Estate Rules 2017 | Maharashtra | 214 |
|
| 136 |
| UP RERA Rules 2016 | Uttar Pradesh | 170 |
|
| 137 |
+
| UP RERA General Regulations 2019 | Uttar Pradesh | 85 |
|
| 138 |
| Karnataka RERA Rules 2017 | Karnataka | 235 |
|
| 139 |
| Tamil Nadu RERA Rules 2017 | Tamil Nadu | 157 |
|
| 140 |
|
frontend/.env.production
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
NEXT_PUBLIC_BACKEND_URL=https://adesh01-civicsetu.hf.space
|
frontend/next-env.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
| 3 |
+
/// <reference path="./.next/types/routes.d.ts" />
|
| 4 |
+
|
| 5 |
+
// NOTE: This file should not be edited
|
| 6 |
+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
frontend/next.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from 'next';
|
| 2 |
+
|
| 3 |
+
const backendUrl = process.env.BACKEND_URL ?? 'http://localhost:8000';
|
| 4 |
+
|
| 5 |
+
const nextConfig: NextConfig = {
|
| 6 |
+
async rewrites() {
|
| 7 |
+
return [
|
| 8 |
+
{ source: '/api/:path*', destination: `${backendUrl}/api/:path*` },
|
| 9 |
+
{ source: '/health', destination: `${backendUrl}/health` },
|
| 10 |
+
];
|
| 11 |
+
},
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export default nextConfig;
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "civicsetu-frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "next lint",
|
| 10 |
+
"typecheck": "tsc --noEmit"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"d3": "^7.9.0",
|
| 14 |
+
"next": "^15.0.0",
|
| 15 |
+
"next-themes": "^0.4.4",
|
| 16 |
+
"react": "^19.0.0",
|
| 17 |
+
"react-dom": "^19.0.0",
|
| 18 |
+
"react-markdown": "^10.1.0",
|
| 19 |
+
"remark-gfm": "^4.0.1"
|
| 20 |
+
},
|
| 21 |
+
"devDependencies": {
|
| 22 |
+
"@types/d3": "^7.4.3",
|
| 23 |
+
"@types/node": "^22.10.2",
|
| 24 |
+
"@types/react": "^19.0.2",
|
| 25 |
+
"@types/react-dom": "^19.0.2",
|
| 26 |
+
"autoprefixer": "^10.4.20",
|
| 27 |
+
"eslint": "^9.17.0",
|
| 28 |
+
"eslint-config-next": "^15.0.0",
|
| 29 |
+
"postcss": "^8.4.49",
|
| 30 |
+
"tailwindcss": "^3.4.17",
|
| 31 |
+
"typescript": "^5.7.2"
|
| 32 |
+
}
|
| 33 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
frontend/src/app/globals.css
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
color-scheme: dark;
|
| 7 |
+
--ledger-bg: #0d0d0d;
|
| 8 |
+
--ledger-panel: #141414;
|
| 9 |
+
--ledger-card: #1a1a1a;
|
| 10 |
+
--ledger-hover: #222222;
|
| 11 |
+
--ledger-fg: #e5e2e1;
|
| 12 |
+
--ledger-muted: rgba(229, 226, 225, 0.42);
|
| 13 |
+
--ledger-ghost: rgba(255, 255, 255, 0.08);
|
| 14 |
+
--ledger-primary: #4f98a3;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.dark {
|
| 18 |
+
color-scheme: dark;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
html,
|
| 22 |
+
body {
|
| 23 |
+
min-height: 100%;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
body {
|
| 27 |
+
margin: 0;
|
| 28 |
+
overflow: hidden;
|
| 29 |
+
background: var(--ledger-bg);
|
| 30 |
+
color: var(--ledger-fg);
|
| 31 |
+
font-family: var(--font-inter), sans-serif;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
@layer base {
|
| 35 |
+
* {
|
| 36 |
+
box-sizing: border-box;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
::selection {
|
| 40 |
+
background: rgba(79, 152, 163, 0.26);
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@layer utilities {
|
| 45 |
+
.ledger-brand {
|
| 46 |
+
font-family: var(--font-instrument-serif), serif;
|
| 47 |
+
font-style: italic;
|
| 48 |
+
color: var(--ledger-primary);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.ledger-scroll {
|
| 52 |
+
scrollbar-width: thin;
|
| 53 |
+
scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.ledger-scroll::-webkit-scrollbar {
|
| 57 |
+
width: 4px;
|
| 58 |
+
height: 4px;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.ledger-scroll::-webkit-scrollbar-track {
|
| 62 |
+
background: transparent;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.ledger-scroll::-webkit-scrollbar-thumb {
|
| 66 |
+
background: rgba(255, 255, 255, 0.12);
|
| 67 |
+
border-radius: 999px;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
@keyframes pulse-glow {
|
| 72 |
+
0%, 100% {
|
| 73 |
+
filter: drop-shadow(0 0 4px currentColor);
|
| 74 |
+
}
|
| 75 |
+
50% {
|
| 76 |
+
filter: drop-shadow(0 0 12px currentColor) drop-shadow(0 0 20px currentColor);
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.node-selected {
|
| 81 |
+
animation: pulse-glow 2s ease-in-out infinite;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.node-hover-glow {
|
| 85 |
+
filter: drop-shadow(0 0 6px currentColor);
|
| 86 |
+
}
|
frontend/src/app/layout.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from 'next';
|
| 2 |
+
import { Instrument_Serif, Inter } from 'next/font/google';
|
| 3 |
+
import { ThemeProvider } from '@/components/ThemeProvider';
|
| 4 |
+
import './globals.css';
|
| 5 |
+
|
| 6 |
+
const inter = Inter({
|
| 7 |
+
subsets: ['latin'],
|
| 8 |
+
variable: '--font-inter',
|
| 9 |
+
display: 'swap',
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
const instrumentSerif = Instrument_Serif({
|
| 13 |
+
subsets: ['latin'],
|
| 14 |
+
weight: ['400'],
|
| 15 |
+
style: ['italic'],
|
| 16 |
+
variable: '--font-instrument-serif',
|
| 17 |
+
display: 'swap',
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
export const metadata: Metadata = {
|
| 21 |
+
title: 'CivicSetu | Digital Ledger',
|
| 22 |
+
description:
|
| 23 |
+
'Query Indian real estate regulations across multiple jurisdictions with cited answers and graph exploration.',
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export default function RootLayout({
|
| 27 |
+
children,
|
| 28 |
+
}: Readonly<{ children: React.ReactNode }>) {
|
| 29 |
+
return (
|
| 30 |
+
<html lang="en" suppressHydrationWarning>
|
| 31 |
+
<body className={`${inter.variable} ${instrumentSerif.variable}`}>
|
| 32 |
+
<ThemeProvider
|
| 33 |
+
attribute="class"
|
| 34 |
+
defaultTheme="dark"
|
| 35 |
+
enableSystem={false}
|
| 36 |
+
disableTransitionOnChange
|
| 37 |
+
>
|
| 38 |
+
{children}
|
| 39 |
+
</ThemeProvider>
|
| 40 |
+
</body>
|
| 41 |
+
</html>
|
| 42 |
+
);
|
| 43 |
+
}
|
frontend/src/app/page.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useCallback, useState } from 'react';
|
| 4 |
+
import { ChatThread } from '@/components/ChatThread';
|
| 5 |
+
import { Header } from '@/components/Header';
|
| 6 |
+
import { InputBar } from '@/components/InputBar';
|
| 7 |
+
import { ContextPill } from '@/components/graph/ContextPill';
|
| 8 |
+
import { GraphExplorer } from '@/components/graph/GraphExplorer';
|
| 9 |
+
import { useChat } from '@/hooks/useChat';
|
| 10 |
+
import { useGraphExplorer } from '@/hooks/useGraphExplorer';
|
| 11 |
+
import type { Jurisdiction, SectionContext } from '@/lib/types';
|
| 12 |
+
|
| 13 |
+
const railItems = [
|
| 14 |
+
{ label: 'Research', glyph: 'R', active: true },
|
| 15 |
+
{ label: 'Legal tools', glyph: 'L', active: false },
|
| 16 |
+
{ label: 'Library', glyph: 'B', active: false },
|
| 17 |
+
{ label: 'History', glyph: 'H', active: false },
|
| 18 |
+
] as const;
|
| 19 |
+
|
| 20 |
+
export default function Home() {
|
| 21 |
+
const { messages, isLoading, sendMessage, sendSectionMessage, newConversation } = useChat();
|
| 22 |
+
const graphState = useGraphExplorer();
|
| 23 |
+
|
| 24 |
+
const [pendingQuery, setPendingQuery] = useState<string | undefined>();
|
| 25 |
+
const [sectionContext, setSectionContext] = useState<SectionContext | null>(null);
|
| 26 |
+
|
| 27 |
+
const handleChatAboutSection = useCallback(
|
| 28 |
+
(sectionId: string, title: string, docName: string, jurisdiction: string) => {
|
| 29 |
+
setSectionContext({ sectionId, title, docName, jurisdiction });
|
| 30 |
+
setPendingQuery(`Explain ${title} of ${docName}`);
|
| 31 |
+
setTimeout(() => setPendingQuery(undefined), 0);
|
| 32 |
+
},
|
| 33 |
+
[],
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
const handleExampleClick = useCallback((query: string) => {
|
| 37 |
+
setPendingQuery(query);
|
| 38 |
+
setTimeout(() => setPendingQuery(undefined), 0);
|
| 39 |
+
}, []);
|
| 40 |
+
|
| 41 |
+
function handleSend(text: string, jurisdiction: Jurisdiction | '') {
|
| 42 |
+
if (sectionContext) {
|
| 43 |
+
void sendSectionMessage(text, sectionContext.sectionId, sectionContext.jurisdiction);
|
| 44 |
+
setSectionContext(null);
|
| 45 |
+
return;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
void sendMessage(text, jurisdiction);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="flex h-screen flex-col overflow-hidden bg-[#0d0d0d] text-[#e5e2e1]">
|
| 53 |
+
<Header onNewConversation={newConversation} />
|
| 54 |
+
|
| 55 |
+
<main className="flex min-h-0 flex-1 overflow-hidden">
|
| 56 |
+
<aside className="flex w-[45%] min-w-[420px] flex-col bg-[#141414] max-lg:w-full max-lg:min-w-0">
|
| 57 |
+
<div className="flex min-h-0 flex-1">
|
| 58 |
+
<nav className="flex w-14 shrink-0 flex-col items-center gap-5 bg-[#111111] py-4">
|
| 59 |
+
{railItems.map(item => (
|
| 60 |
+
<button
|
| 61 |
+
key={item.label}
|
| 62 |
+
type="button"
|
| 63 |
+
aria-label={item.label}
|
| 64 |
+
className={`grid h-8 w-8 place-items-center rounded-[6px] text-sm transition-[background-color,color,transform] duration-150 ease-out active:scale-95 ${
|
| 65 |
+
item.active
|
| 66 |
+
? 'bg-[#222222] text-[#4f98a3]'
|
| 67 |
+
: 'text-white/30 hover:bg-[#222222] hover:text-white/70'
|
| 68 |
+
}`}
|
| 69 |
+
>
|
| 70 |
+
{item.glyph}
|
| 71 |
+
</button>
|
| 72 |
+
))}
|
| 73 |
+
<button
|
| 74 |
+
type="button"
|
| 75 |
+
aria-label="Settings"
|
| 76 |
+
className="mt-auto grid h-8 w-8 place-items-center rounded-[6px] text-sm text-white/30 transition-[background-color,color,transform] duration-150 ease-out hover:bg-[#222222] hover:text-white/70 active:scale-95"
|
| 77 |
+
>
|
| 78 |
+
S
|
| 79 |
+
</button>
|
| 80 |
+
</nav>
|
| 81 |
+
|
| 82 |
+
<div className="flex min-w-0 flex-1 flex-col">
|
| 83 |
+
<div className="flex h-14 shrink-0 items-center justify-between bg-[#141414] px-4">
|
| 84 |
+
<button
|
| 85 |
+
type="button"
|
| 86 |
+
onClick={newConversation}
|
| 87 |
+
className="inline-flex items-center gap-2 rounded-[6px] bg-[#1a1a1a] px-3 py-1.5 text-xs text-white/60 transition-[background-color,color,transform] duration-150 ease-out hover:bg-[#222222] hover:text-white/80 active:scale-[0.98]"
|
| 88 |
+
>
|
| 89 |
+
<span className="text-sm">+</span>
|
| 90 |
+
New Conversation
|
| 91 |
+
</button>
|
| 92 |
+
<span className="font-mono text-[10px] uppercase tracking-[0.28em] text-white/25">
|
| 93 |
+
Active session
|
| 94 |
+
</span>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
{sectionContext ? (
|
| 98 |
+
<ContextPill
|
| 99 |
+
sectionId={sectionContext.sectionId}
|
| 100 |
+
docName={sectionContext.docName}
|
| 101 |
+
jurisdiction={sectionContext.jurisdiction}
|
| 102 |
+
onRemove={() => setSectionContext(null)}
|
| 103 |
+
/>
|
| 104 |
+
) : null}
|
| 105 |
+
|
| 106 |
+
<div className="min-h-0 flex-1 overflow-hidden">
|
| 107 |
+
<ChatThread messages={messages} isLoading={isLoading} onExampleClick={handleExampleClick} />
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<InputBar onSend={handleSend} disabled={isLoading} pendingQuery={pendingQuery} />
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
</aside>
|
| 114 |
+
|
| 115 |
+
<section className="flex w-[55%] min-w-0 flex-col bg-[#0d0d0d] max-lg:hidden">
|
| 116 |
+
<GraphExplorer {...graphState} onChatAboutSection={handleChatAboutSection} />
|
| 117 |
+
</section>
|
| 118 |
+
</main>
|
| 119 |
+
|
| 120 |
+
<footer className="flex h-6 shrink-0 items-center justify-between bg-[#0d0d0d] px-4 font-mono text-[10px] uppercase tracking-tight text-zinc-600">
|
| 121 |
+
<div className="flex items-center gap-4">
|
| 122 |
+
<span>DB_LATENCY: 12ms</span>
|
| 123 |
+
<span>CORPUS: RERA_INDIA_2026</span>
|
| 124 |
+
</div>
|
| 125 |
+
<div className="flex items-center gap-1.5 text-[#4f98a3]">
|
| 126 |
+
<span className="h-1 w-1 rounded-full bg-[#4f98a3]" />
|
| 127 |
+
System ready
|
| 128 |
+
</div>
|
| 129 |
+
</footer>
|
| 130 |
+
</div>
|
| 131 |
+
);
|
| 132 |
+
}
|
frontend/src/components/ChatThread.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef } from 'react';
|
| 4 |
+
import { MessageBubble } from '@/components/MessageBubble';
|
| 5 |
+
import type { ChatMessage } from '@/lib/types';
|
| 6 |
+
|
| 7 |
+
const EXAMPLE_QUERIES = [
|
| 8 |
+
'What are promoter obligations under RERA?',
|
| 9 |
+
'What penalty applies for delayed possession?',
|
| 10 |
+
'How does agent registration work under Maharashtra rules?',
|
| 11 |
+
'What is the complaint filing process for a buyer?',
|
| 12 |
+
] as const;
|
| 13 |
+
|
| 14 |
+
interface Props {
|
| 15 |
+
messages: ChatMessage[];
|
| 16 |
+
isLoading: boolean;
|
| 17 |
+
onExampleClick: (query: string) => void;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function ChatThread({ messages, isLoading, onExampleClick }: Props) {
|
| 21 |
+
const bottomRef = useRef<HTMLDivElement>(null);
|
| 22 |
+
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 25 |
+
}, [messages, isLoading]);
|
| 26 |
+
|
| 27 |
+
if (messages.length === 0 && !isLoading) {
|
| 28 |
+
return (
|
| 29 |
+
<div className="ledger-scroll flex h-full flex-col justify-center overflow-y-auto px-4 py-8">
|
| 30 |
+
<div className="max-w-md">
|
| 31 |
+
<p className="font-mono text-[10px] uppercase tracking-[0.28em] text-white/25">Research desk</p>
|
| 32 |
+
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.02em] text-white/84">
|
| 33 |
+
Ask the ledger a legal question.
|
| 34 |
+
</h2>
|
| 35 |
+
<p className="mt-3 text-sm leading-6 text-white/50">
|
| 36 |
+
Query Indian RERA provisions, compare jurisdictions, and route selected graph sections directly into the chat.
|
| 37 |
+
</p>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div className="mt-8 grid gap-2">
|
| 41 |
+
{EXAMPLE_QUERIES.map(query => (
|
| 42 |
+
<button
|
| 43 |
+
key={query}
|
| 44 |
+
type="button"
|
| 45 |
+
onClick={() => onExampleClick(query)}
|
| 46 |
+
className="rounded-[10px] bg-[#1a1a1a] px-3.5 py-3 text-left text-[13px] leading-5 text-white/60 transition-colors hover:bg-[#222222] hover:text-white/80"
|
| 47 |
+
>
|
| 48 |
+
{query}
|
| 49 |
+
</button>
|
| 50 |
+
))}
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return (
|
| 57 |
+
<div className="ledger-scroll flex h-full flex-col gap-6 overflow-y-auto p-4">
|
| 58 |
+
{messages.map(message => (
|
| 59 |
+
<MessageBubble key={message.id} message={message} />
|
| 60 |
+
))}
|
| 61 |
+
|
| 62 |
+
{isLoading ? (
|
| 63 |
+
<div className="flex gap-1.5 self-start p-2">
|
| 64 |
+
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/20" />
|
| 65 |
+
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/20 [animation-delay:200ms]" />
|
| 66 |
+
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/20 [animation-delay:400ms]" />
|
| 67 |
+
</div>
|
| 68 |
+
) : null}
|
| 69 |
+
|
| 70 |
+
<div ref={bottomRef} />
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
}
|
frontend/src/components/CitationsPanel.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react';
|
| 4 |
+
import { JURISDICTION_COLORS, JURISDICTION_LABELS } from '@/lib/constants';
|
| 5 |
+
import type { Citation } from '@/lib/types';
|
| 6 |
+
|
| 7 |
+
interface Props {
|
| 8 |
+
citations: Citation[];
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function CitationsPanel({ citations }: Props) {
|
| 12 |
+
const [open, setOpen] = useState(false);
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className="mt-3">
|
| 16 |
+
<button
|
| 17 |
+
type="button"
|
| 18 |
+
onClick={() => setOpen(value => !value)}
|
| 19 |
+
className="inline-flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.2em] text-white/30 transition-colors hover:text-[#4f98a3] active:scale-[0.98]"
|
| 20 |
+
>
|
| 21 |
+
<span className={`transition-transform duration-150 ease-out ${open ? 'rotate-180' : ''}`}>▼</span>
|
| 22 |
+
{citations.length} citation{citations.length === 1 ? '' : 's'}
|
| 23 |
+
</button>
|
| 24 |
+
|
| 25 |
+
{open ? (
|
| 26 |
+
<div className="mt-3 space-y-2">
|
| 27 |
+
{citations.map(citation => {
|
| 28 |
+
const color = JURISDICTION_COLORS[citation.jurisdiction] ?? '#888';
|
| 29 |
+
return (
|
| 30 |
+
<article key={citation.chunk_id} className="border border-white/[0.07] bg-white/[0.025] p-3">
|
| 31 |
+
<div className="flex items-start justify-between gap-3">
|
| 32 |
+
<div className="min-w-0 space-y-1">
|
| 33 |
+
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-white/30">
|
| 34 |
+
Sec {citation.section_id}
|
| 35 |
+
</p>
|
| 36 |
+
<p className="truncate text-[12px] font-medium text-white/70">{citation.doc_name}</p>
|
| 37 |
+
</div>
|
| 38 |
+
<span
|
| 39 |
+
className="shrink-0 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em]"
|
| 40 |
+
style={{ backgroundColor: `${color}24`, color }}
|
| 41 |
+
>
|
| 42 |
+
{JURISDICTION_LABELS[citation.jurisdiction] ?? citation.jurisdiction}
|
| 43 |
+
</span>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-[9px] uppercase tracking-[0.16em] text-white/30">
|
| 47 |
+
<span>Effective {citation.effective_date ?? 'not listed'}</span>
|
| 48 |
+
<a
|
| 49 |
+
href={citation.source_url}
|
| 50 |
+
target="_blank"
|
| 51 |
+
rel="noreferrer"
|
| 52 |
+
className="text-[#4f98a3] transition-colors hover:text-[#8ad2de]"
|
| 53 |
+
>
|
| 54 |
+
Source
|
| 55 |
+
</a>
|
| 56 |
+
</div>
|
| 57 |
+
</article>
|
| 58 |
+
);
|
| 59 |
+
})}
|
| 60 |
+
</div>
|
| 61 |
+
) : null}
|
| 62 |
+
</div>
|
| 63 |
+
);
|
| 64 |
+
}
|
frontend/src/components/ConfidenceBadge.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface Props {
|
| 2 |
+
score: number;
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
const LEVELS = {
|
| 6 |
+
HIGH: {
|
| 7 |
+
label: 'HIGH',
|
| 8 |
+
classes: 'border-emerald-300/20 bg-emerald-300/10 text-emerald-200/80',
|
| 9 |
+
},
|
| 10 |
+
MEDIUM: {
|
| 11 |
+
label: 'MEDIUM',
|
| 12 |
+
classes: 'border-amber-300/20 bg-amber-300/10 text-amber-200/80',
|
| 13 |
+
},
|
| 14 |
+
LOW: {
|
| 15 |
+
label: 'LOW',
|
| 16 |
+
classes: 'border-rose-300/20 bg-rose-300/10 text-rose-200/80',
|
| 17 |
+
},
|
| 18 |
+
} as const;
|
| 19 |
+
|
| 20 |
+
export function ConfidenceBadge({ score }: Props) {
|
| 21 |
+
const key = score >= 0.75 ? 'HIGH' : score >= 0.5 ? 'MEDIUM' : 'LOW';
|
| 22 |
+
const { label, classes } = LEVELS[key];
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<span
|
| 26 |
+
className={`inline-flex items-center border px-2 py-0.5 font-mono text-[9px] font-medium uppercase tracking-[0.18em] ${classes}`}
|
| 27 |
+
>
|
| 28 |
+
{label} {(score * 100).toFixed(0)}%
|
| 29 |
+
</span>
|
| 30 |
+
);
|
| 31 |
+
}
|
frontend/src/components/Header.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useTheme } from 'next-themes';
|
| 4 |
+
import { useEffect, useState } from 'react';
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
onNewConversation: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function Header({ onNewConversation: _onNewConversation }: Props) {
|
| 11 |
+
const { resolvedTheme, setTheme } = useTheme();
|
| 12 |
+
const [mounted, setMounted] = useState(false);
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
setMounted(true);
|
| 16 |
+
}, []);
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<header className="z-20 flex h-12 shrink-0 items-center justify-between bg-[#0d0d0d] px-6">
|
| 20 |
+
<div className="flex w-full items-center justify-between">
|
| 21 |
+
<div className="flex items-center gap-2">
|
| 22 |
+
<span className="text-[#4f98a3]">CS</span>
|
| 23 |
+
<h1 className="ledger-brand text-xl">CivicSetu</h1>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div className="flex items-center gap-4">
|
| 27 |
+
{mounted ? (
|
| 28 |
+
<button
|
| 29 |
+
type="button"
|
| 30 |
+
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
| 31 |
+
aria-label={resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
| 32 |
+
className="grid h-8 w-8 place-items-center rounded-[6px] text-zinc-400 transition-[background-color,color,transform] duration-150 ease-out hover:bg-white/5 hover:text-white/70 active:scale-95"
|
| 33 |
+
>
|
| 34 |
+
{resolvedTheme === 'dark' ? <MoonIcon /> : <SunIcon />}
|
| 35 |
+
</button>
|
| 36 |
+
) : null}
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</header>
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function MoonIcon() {
|
| 44 |
+
return (
|
| 45 |
+
<svg
|
| 46 |
+
aria-hidden="true"
|
| 47 |
+
className="h-4 w-4"
|
| 48 |
+
viewBox="0 0 24 24"
|
| 49 |
+
fill="none"
|
| 50 |
+
stroke="currentColor"
|
| 51 |
+
strokeWidth="1.8"
|
| 52 |
+
strokeLinecap="round"
|
| 53 |
+
strokeLinejoin="round"
|
| 54 |
+
>
|
| 55 |
+
<path d="M20.2 14.8A8.5 8.5 0 0 1 9.2 3.8 8 8 0 1 0 20.2 14.8Z" />
|
| 56 |
+
</svg>
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function SunIcon() {
|
| 61 |
+
return (
|
| 62 |
+
<svg
|
| 63 |
+
aria-hidden="true"
|
| 64 |
+
className="h-4 w-4"
|
| 65 |
+
viewBox="0 0 24 24"
|
| 66 |
+
fill="none"
|
| 67 |
+
stroke="currentColor"
|
| 68 |
+
strokeWidth="1.8"
|
| 69 |
+
strokeLinecap="round"
|
| 70 |
+
strokeLinejoin="round"
|
| 71 |
+
>
|
| 72 |
+
<circle cx="12" cy="12" r="3.5" />
|
| 73 |
+
<path d="M12 2.8v2.4" />
|
| 74 |
+
<path d="M12 18.8v2.4" />
|
| 75 |
+
<path d="m4.7 4.7 1.7 1.7" />
|
| 76 |
+
<path d="m17.6 17.6 1.7 1.7" />
|
| 77 |
+
<path d="M2.8 12h2.4" />
|
| 78 |
+
<path d="M18.8 12h2.4" />
|
| 79 |
+
<path d="m4.7 19.3 1.7-1.7" />
|
| 80 |
+
<path d="m17.6 6.4 1.7-1.7" />
|
| 81 |
+
</svg>
|
| 82 |
+
);
|
| 83 |
+
}
|
frontend/src/components/InputBar.tsx
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef, useState } from 'react';
|
| 4 |
+
import type { Jurisdiction } from '@/lib/types';
|
| 5 |
+
|
| 6 |
+
const JURISDICTIONS = [
|
| 7 |
+
{ value: '' as const, label: 'All Jurisdictions', shortLabel: 'All', dotClass: 'bg-white/35' },
|
| 8 |
+
{ value: 'CENTRAL' as const, label: 'Central', shortLabel: 'Central', dotClass: 'bg-[#2db7a3]' },
|
| 9 |
+
{ value: 'MAHARASHTRA' as const, label: 'Maharashtra', shortLabel: 'Maharashtra', dotClass: 'bg-[#4e7cff]' },
|
| 10 |
+
{ value: 'UTTAR_PRADESH' as const, label: 'Uttar Pradesh', shortLabel: 'Uttar Pradesh', dotClass: 'bg-[#f4b63f]' },
|
| 11 |
+
{ value: 'KARNATAKA' as const, label: 'Karnataka', shortLabel: 'Karnataka', dotClass: 'bg-[#78b94d]' },
|
| 12 |
+
{ value: 'TAMIL_NADU' as const, label: 'Tamil Nadu', shortLabel: 'Tamil Nadu', dotClass: 'bg-[#ff7a59]' },
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
interface Props {
|
| 16 |
+
onSend: (query: string, jurisdiction: Jurisdiction | '') => void;
|
| 17 |
+
disabled: boolean;
|
| 18 |
+
pendingQuery?: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function InputBar({ onSend, disabled, pendingQuery }: Props) {
|
| 22 |
+
const [query, setQuery] = useState('');
|
| 23 |
+
const [jurisdiction, setJurisdiction] = useState<Jurisdiction | ''>('');
|
| 24 |
+
const [isJurisdictionOpen, setIsJurisdictionOpen] = useState(false);
|
| 25 |
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 26 |
+
const jurisdictionMenuRef = useRef<HTMLDivElement>(null);
|
| 27 |
+
const MAX_VISIBLE_LINES = 9;
|
| 28 |
+
|
| 29 |
+
const selectedJurisdiction = JURISDICTIONS.find(item => item.value === jurisdiction) ?? JURISDICTIONS[0];
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
if (!pendingQuery) {
|
| 33 |
+
return;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
setQuery(pendingQuery);
|
| 37 |
+
requestAnimationFrame(() => {
|
| 38 |
+
textareaRef.current?.focus();
|
| 39 |
+
autoResize();
|
| 40 |
+
});
|
| 41 |
+
}, [pendingQuery]);
|
| 42 |
+
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
if (!isJurisdictionOpen) {
|
| 45 |
+
return;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function handlePointerDown(event: PointerEvent) {
|
| 49 |
+
if (!jurisdictionMenuRef.current?.contains(event.target as Node)) {
|
| 50 |
+
setIsJurisdictionOpen(false);
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
window.addEventListener('pointerdown', handlePointerDown);
|
| 55 |
+
return () => window.removeEventListener('pointerdown', handlePointerDown);
|
| 56 |
+
}, [isJurisdictionOpen]);
|
| 57 |
+
|
| 58 |
+
function autoResize() {
|
| 59 |
+
const element = textareaRef.current;
|
| 60 |
+
if (!element) {
|
| 61 |
+
return;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const computed = window.getComputedStyle(element);
|
| 65 |
+
const lineHeight = Number.parseFloat(computed.lineHeight) || 24;
|
| 66 |
+
const maxHeight = lineHeight * MAX_VISIBLE_LINES;
|
| 67 |
+
element.style.height = 'auto';
|
| 68 |
+
element.style.height = `${Math.min(element.scrollHeight, maxHeight)}px`;
|
| 69 |
+
element.style.overflowY = element.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function handleInput(event: React.ChangeEvent<HTMLTextAreaElement>) {
|
| 73 |
+
setQuery(event.target.value);
|
| 74 |
+
autoResize();
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function handleSend() {
|
| 78 |
+
const text = query.trim();
|
| 79 |
+
if (!text || disabled) {
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
onSend(text, jurisdiction);
|
| 84 |
+
setQuery('');
|
| 85 |
+
if (textareaRef.current) {
|
| 86 |
+
textareaRef.current.style.height = 'auto';
|
| 87 |
+
textareaRef.current.style.overflowY = 'hidden';
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
|
| 92 |
+
if (event.key === 'Enter' && !event.shiftKey) {
|
| 93 |
+
event.preventDefault();
|
| 94 |
+
handleSend();
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
return (
|
| 99 |
+
<div className="shrink-0 bg-[#0d0d0d] p-3">
|
| 100 |
+
<div ref={jurisdictionMenuRef} className="relative mb-1 w-fit">
|
| 101 |
+
<button
|
| 102 |
+
type="button"
|
| 103 |
+
onClick={() => setIsJurisdictionOpen(value => !value)}
|
| 104 |
+
className="inline-flex h-7 items-center gap-2 rounded-[7px] border border-white/[0.07] bg-[#141414] px-2.5 font-mono text-[10px] uppercase tracking-[0.2em] text-white/50 transition-[background-color,border-color,color,transform] duration-150 ease-out hover:border-white/15 hover:bg-[#1a1a1a] hover:text-white/70 active:scale-[0.98]"
|
| 105 |
+
aria-haspopup="listbox"
|
| 106 |
+
aria-expanded={isJurisdictionOpen}
|
| 107 |
+
>
|
| 108 |
+
<span className={`h-1.5 w-1.5 rounded-full ${selectedJurisdiction.dotClass}`} />
|
| 109 |
+
{selectedJurisdiction.shortLabel}
|
| 110 |
+
<span className={`text-white/25 transition-transform duration-150 ease-out ${isJurisdictionOpen ? 'rotate-180' : ''}`}>
|
| 111 |
+
▼
|
| 112 |
+
</span>
|
| 113 |
+
</button>
|
| 114 |
+
|
| 115 |
+
{isJurisdictionOpen ? (
|
| 116 |
+
<div
|
| 117 |
+
role="listbox"
|
| 118 |
+
className="absolute bottom-8 left-0 z-30 w-52 border border-white/[0.08] bg-[#141414]/98 p-1 shadow-[0_18px_50px_rgba(0,0,0,0.45)] backdrop-blur"
|
| 119 |
+
>
|
| 120 |
+
{JURISDICTIONS.map(item => (
|
| 121 |
+
<button
|
| 122 |
+
key={item.value}
|
| 123 |
+
type="button"
|
| 124 |
+
role="option"
|
| 125 |
+
aria-selected={jurisdiction === item.value}
|
| 126 |
+
onClick={() => {
|
| 127 |
+
setJurisdiction(item.value);
|
| 128 |
+
setIsJurisdictionOpen(false);
|
| 129 |
+
}}
|
| 130 |
+
className={`flex w-full items-center justify-between gap-3 rounded-[6px] px-2.5 py-2 text-left text-[12px] transition-[background-color,color] duration-150 ease-out ${
|
| 131 |
+
jurisdiction === item.value
|
| 132 |
+
? 'bg-[#4f98a3]/12 text-[#9ed4dc]'
|
| 133 |
+
: 'text-white/60 hover:bg-white/[0.04] hover:text-white/80'
|
| 134 |
+
}`}
|
| 135 |
+
>
|
| 136 |
+
<span className="flex min-w-0 items-center gap-2">
|
| 137 |
+
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${item.dotClass}`} />
|
| 138 |
+
<span className="truncate">{item.label}</span>
|
| 139 |
+
</span>
|
| 140 |
+
{jurisdiction === item.value ? (
|
| 141 |
+
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${item.dotClass}`} />
|
| 142 |
+
) : null}
|
| 143 |
+
</button>
|
| 144 |
+
))}
|
| 145 |
+
</div>
|
| 146 |
+
) : null}
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div className="relative flex items-end">
|
| 150 |
+
<textarea
|
| 151 |
+
ref={textareaRef}
|
| 152 |
+
value={query}
|
| 153 |
+
onChange={handleInput}
|
| 154 |
+
onKeyDown={handleKeyDown}
|
| 155 |
+
rows={1}
|
| 156 |
+
placeholder="Ask about RERA regulations..."
|
| 157 |
+
className="ledger-scroll min-h-[40px] w-full resize-none overflow-hidden rounded-[10px] bg-[#1a1a1a] py-2 pl-3 pr-10 text-[14px] leading-6 text-white/85 outline-none transition-[border-color,height] duration-150 ease-out placeholder:text-white/25 focus:ring-1 focus:ring-[#4f98a3]/50"
|
| 158 |
+
/>
|
| 159 |
+
<button
|
| 160 |
+
type="button"
|
| 161 |
+
onClick={handleSend}
|
| 162 |
+
disabled={disabled || !query.trim()}
|
| 163 |
+
aria-label="Send message"
|
| 164 |
+
className="absolute bottom-2 right-3 text-[#4f98a3] transition-opacity hover:opacity-80 disabled:opacity-25"
|
| 165 |
+
>
|
| 166 |
+
->
|
| 167 |
+
</button>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
);
|
| 171 |
+
}
|
frontend/src/components/MessageBubble.tsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ReactMarkdown from 'react-markdown';
|
| 2 |
+
import remarkGfm from 'remark-gfm';
|
| 3 |
+
import { CitationsPanel } from '@/components/CitationsPanel';
|
| 4 |
+
import { ConfidenceBadge } from '@/components/ConfidenceBadge';
|
| 5 |
+
import type { ChatMessage } from '@/lib/types';
|
| 6 |
+
import { isCivicSetuResponse } from '@/lib/types';
|
| 7 |
+
|
| 8 |
+
interface Props {
|
| 9 |
+
message: ChatMessage;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function MessageBubble({ message }: Props) {
|
| 13 |
+
if (message.role === 'user') {
|
| 14 |
+
return (
|
| 15 |
+
<div className="flex justify-end">
|
| 16 |
+
<div className="max-w-[85%] rounded-[12px] bg-white/5 px-3.5 py-2.5 text-[13px] leading-6 text-white/85">
|
| 17 |
+
<p className="whitespace-pre-wrap">{message.text}</p>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
if (message.role === 'error') {
|
| 24 |
+
return (
|
| 25 |
+
<div className="max-w-[95%] self-start rounded-[10px] bg-red-950/25 px-3.5 py-3 text-[13px] leading-6 text-red-200/80">
|
| 26 |
+
{message.text}
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const data = message.data;
|
| 32 |
+
const isRichResponse = data !== undefined && isCivicSetuResponse(data);
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<div className="flex max-w-[95%] flex-col gap-3 self-start">
|
| 36 |
+
<div className="text-[14px] leading-7 text-white/70">
|
| 37 |
+
<ReactMarkdown
|
| 38 |
+
remarkPlugins={[remarkGfm]}
|
| 39 |
+
components={{
|
| 40 |
+
p: ({ children }) => <p className="mb-4 last:mb-0">{children}</p>,
|
| 41 |
+
h1: ({ children }) => <h1 className="mb-3 text-xl font-semibold leading-7 text-white/90">{children}</h1>,
|
| 42 |
+
h2: ({ children }) => <h2 className="mb-3 text-lg font-semibold leading-7 text-white/90">{children}</h2>,
|
| 43 |
+
h3: ({ children }) => <h3 className="mb-2 text-base font-semibold leading-6 text-white/85">{children}</h3>,
|
| 44 |
+
ul: ({ children }) => <ul className="mb-4 list-disc space-y-1 pl-5 last:mb-0">{children}</ul>,
|
| 45 |
+
ol: ({ children }) => <ol className="mb-4 list-decimal space-y-1 pl-5 last:mb-0">{children}</ol>,
|
| 46 |
+
li: ({ children }) => <li className="pl-1">{children}</li>,
|
| 47 |
+
strong: ({ children }) => <strong className="font-semibold text-white/90">{children}</strong>,
|
| 48 |
+
blockquote: ({ children }) => (
|
| 49 |
+
<blockquote className="mb-4 border-l-2 border-[#4f98a3]/50 pl-4 text-white/55 last:mb-0">
|
| 50 |
+
{children}
|
| 51 |
+
</blockquote>
|
| 52 |
+
),
|
| 53 |
+
hr: () => <hr className="my-5 border-white/10" />,
|
| 54 |
+
table: ({ children }) => (
|
| 55 |
+
<div className="ledger-scroll mb-4 overflow-x-auto last:mb-0">
|
| 56 |
+
<table className="min-w-full border-collapse text-left text-[13px] leading-6">
|
| 57 |
+
{children}
|
| 58 |
+
</table>
|
| 59 |
+
</div>
|
| 60 |
+
),
|
| 61 |
+
thead: ({ children }) => <thead className="border-b border-white/15 text-white/85">{children}</thead>,
|
| 62 |
+
tbody: ({ children }) => <tbody className="divide-y divide-white/10">{children}</tbody>,
|
| 63 |
+
th: ({ children }) => <th className="px-3 py-2 font-semibold">{children}</th>,
|
| 64 |
+
td: ({ children }) => <td className="px-3 py-2 align-top text-white/65">{children}</td>,
|
| 65 |
+
code: ({ children, className }) => (
|
| 66 |
+
<code className={`rounded bg-white/10 px-1.5 py-0.5 font-mono text-[0.92em] text-white/80 ${className ?? ''}`}>
|
| 67 |
+
{children}
|
| 68 |
+
</code>
|
| 69 |
+
),
|
| 70 |
+
pre: ({ children }) => (
|
| 71 |
+
<pre className="ledger-scroll mb-4 overflow-x-auto rounded-[10px] bg-[#1a1a1a] px-4 py-3 font-mono text-xs text-white/70 last:mb-0">
|
| 72 |
+
{children}
|
| 73 |
+
</pre>
|
| 74 |
+
),
|
| 75 |
+
a: ({ children, href }) => (
|
| 76 |
+
<a
|
| 77 |
+
href={href}
|
| 78 |
+
target="_blank"
|
| 79 |
+
rel="noreferrer"
|
| 80 |
+
className="font-medium text-[#4f98a3] underline decoration-[#4f98a3]/40 underline-offset-4 hover:text-[#72bdc6]"
|
| 81 |
+
>
|
| 82 |
+
{children}
|
| 83 |
+
</a>
|
| 84 |
+
),
|
| 85 |
+
}}
|
| 86 |
+
>
|
| 87 |
+
{message.text}
|
| 88 |
+
</ReactMarkdown>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{isRichResponse && data.citations.length > 0 ? <CitationsPanel citations={data.citations} /> : null}
|
| 92 |
+
|
| 93 |
+
{isRichResponse ? (
|
| 94 |
+
<div className="flex flex-col gap-1.5">
|
| 95 |
+
<div className="flex items-center gap-2">
|
| 96 |
+
<span className="text-[12px] text-white/30">Confidence:</span>
|
| 97 |
+
<ConfidenceBadge score={data.confidence_score} />
|
| 98 |
+
</div>
|
| 99 |
+
{data.conflict_warnings.length > 0 ? (
|
| 100 |
+
<p className="text-xs leading-5 text-amber-200/70">Conflict warning: {data.conflict_warnings.join(', ')}</p>
|
| 101 |
+
) : null}
|
| 102 |
+
{data.amendment_notice ? (
|
| 103 |
+
<p className="text-xs leading-5 text-[#8ad2de]/75">Amendment notice: {data.amendment_notice}</p>
|
| 104 |
+
) : null}
|
| 105 |
+
</div>
|
| 106 |
+
) : null}
|
| 107 |
+
</div>
|
| 108 |
+
);
|
| 109 |
+
}
|
frontend/src/components/ThemeProvider.tsx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
| 4 |
+
import type { ThemeProviderProps } from 'next-themes';
|
| 5 |
+
|
| 6 |
+
export function ThemeProvider(props: ThemeProviderProps) {
|
| 7 |
+
return <NextThemesProvider {...props} />;
|
| 8 |
+
}
|
frontend/src/components/graph/ContextPill.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
interface Props {
|
| 4 |
+
sectionId: string;
|
| 5 |
+
docName: string;
|
| 6 |
+
jurisdiction: string;
|
| 7 |
+
onRemove: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function ContextPill({ sectionId, docName, jurisdiction, onRemove }: Props) {
|
| 11 |
+
return (
|
| 12 |
+
<div className="flex h-10 shrink-0 items-center justify-between bg-[#141414] px-3">
|
| 13 |
+
<span className="truncate text-[12px] text-white/60">
|
| 14 |
+
Sec {sectionId} / {docName} / {jurisdiction}
|
| 15 |
+
</span>
|
| 16 |
+
<button
|
| 17 |
+
onClick={onRemove}
|
| 18 |
+
className="text-white/30 transition-colors hover:text-white/70 active:scale-95"
|
| 19 |
+
aria-label="Remove section context"
|
| 20 |
+
type="button"
|
| 21 |
+
>
|
| 22 |
+
x
|
| 23 |
+
</button>
|
| 24 |
+
</div>
|
| 25 |
+
);
|
| 26 |
+
}
|
frontend/src/components/graph/ForceGraph.tsx
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import * as d3 from 'd3';
|
| 4 |
+
import { useEffect, useRef } from 'react';
|
| 5 |
+
import { EDGE_STYLES, JURISDICTION_COLORS, NODE_RADIUS } from '@/lib/constants';
|
| 6 |
+
import type { GraphEdge, GraphFilters, GraphNode } from '@/lib/types';
|
| 7 |
+
|
| 8 |
+
interface Props {
|
| 9 |
+
nodes: GraphNode[];
|
| 10 |
+
edges: GraphEdge[];
|
| 11 |
+
filters: GraphFilters;
|
| 12 |
+
selectedNode: GraphNode | null;
|
| 13 |
+
hoveredNode: GraphNode | null;
|
| 14 |
+
onNodeClick: (node: GraphNode | null) => void;
|
| 15 |
+
onNodeHover: (node: GraphNode | null) => void;
|
| 16 |
+
width: number;
|
| 17 |
+
height: number;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function ForceGraph({
|
| 21 |
+
nodes,
|
| 22 |
+
edges,
|
| 23 |
+
filters,
|
| 24 |
+
selectedNode,
|
| 25 |
+
hoveredNode,
|
| 26 |
+
onNodeClick,
|
| 27 |
+
onNodeHover,
|
| 28 |
+
width,
|
| 29 |
+
height,
|
| 30 |
+
}: Props) {
|
| 31 |
+
const svgRef = useRef<SVGSVGElement>(null);
|
| 32 |
+
const simRef = useRef<d3.Simulation<GraphNode, GraphEdge> | null>(null);
|
| 33 |
+
const zoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null);
|
| 34 |
+
|
| 35 |
+
const filteredNodes = nodes.filter(node => filters.jurisdictions.has(node.jurisdiction));
|
| 36 |
+
const filteredNodeIds = new Set(filteredNodes.map(node => node.chunk_id));
|
| 37 |
+
const filteredEdges = edges.filter(
|
| 38 |
+
edge =>
|
| 39 |
+
filters.edgeTypes.has(edge.edge_type) &&
|
| 40 |
+
filteredNodeIds.has(typeof edge.source === 'string' ? edge.source : edge.source.chunk_id) &&
|
| 41 |
+
filteredNodeIds.has(typeof edge.target === 'string' ? edge.target : edge.target.chunk_id),
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
if (!svgRef.current || width === 0 || height === 0) {
|
| 46 |
+
return;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const svg = d3.select(svgRef.current);
|
| 50 |
+
svg.selectAll('*').remove();
|
| 51 |
+
|
| 52 |
+
const defs = svg.append('defs');
|
| 53 |
+
const filter = defs.append('filter').attr('id', 'node-glow');
|
| 54 |
+
filter.append('feGaussianBlur').attr('stdDeviation', '3').attr('result', 'blur');
|
| 55 |
+
const merge = filter.append('feMerge');
|
| 56 |
+
merge.append('feMergeNode').attr('in', 'blur');
|
| 57 |
+
merge.append('feMergeNode').attr('in', 'SourceGraphic');
|
| 58 |
+
|
| 59 |
+
const container = svg.append('g').attr('class', 'zoom-container');
|
| 60 |
+
const edgeGroup = container.append('g').attr('class', 'edges');
|
| 61 |
+
const nodeGroup = container.append('g').attr('class', 'nodes');
|
| 62 |
+
|
| 63 |
+
const simNodes: GraphNode[] = filteredNodes.map(node => ({ ...node }));
|
| 64 |
+
const chunkIdToNode = new Map(simNodes.map(node => [node.chunk_id, node]));
|
| 65 |
+
const simEdges: GraphEdge[] = filteredEdges.map(edge => ({
|
| 66 |
+
...edge,
|
| 67 |
+
source: typeof edge.source === 'string' ? edge.source : edge.source.chunk_id,
|
| 68 |
+
target: typeof edge.target === 'string' ? edge.target : edge.target.chunk_id,
|
| 69 |
+
}));
|
| 70 |
+
|
| 71 |
+
const connectionCounts = simNodes.map(node => node.connection_count);
|
| 72 |
+
const minConn = Math.min(...connectionCounts, 1);
|
| 73 |
+
const maxConn = Math.max(...connectionCounts, 2);
|
| 74 |
+
const radiusScale = d3.scaleSqrt().domain([minConn, maxConn]).range([NODE_RADIUS.MIN, NODE_RADIUS.MAX]);
|
| 75 |
+
const getRadius = (node: GraphNode) =>
|
| 76 |
+
filters.nodeSizeMode === 'uniform' ? NODE_RADIUS.DEFAULT : radiusScale(node.connection_count);
|
| 77 |
+
|
| 78 |
+
const sim = d3
|
| 79 |
+
.forceSimulation<GraphNode>(simNodes)
|
| 80 |
+
.force(
|
| 81 |
+
'link',
|
| 82 |
+
d3
|
| 83 |
+
.forceLink<GraphNode, GraphEdge>(simEdges)
|
| 84 |
+
.id(node => node.chunk_id)
|
| 85 |
+
.distance(82)
|
| 86 |
+
.strength(0.38),
|
| 87 |
+
)
|
| 88 |
+
.force('charge', d3.forceManyBody<GraphNode>().strength(-64).distanceMax(310))
|
| 89 |
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
| 90 |
+
.force('collide', d3.forceCollide<GraphNode>(node => getRadius(node) + 3))
|
| 91 |
+
.alphaDecay(0.02)
|
| 92 |
+
.velocityDecay(0.42);
|
| 93 |
+
|
| 94 |
+
simRef.current = sim;
|
| 95 |
+
|
| 96 |
+
const link = edgeGroup
|
| 97 |
+
.selectAll<SVGLineElement, GraphEdge>('line')
|
| 98 |
+
.data(simEdges)
|
| 99 |
+
.join('line')
|
| 100 |
+
.attr('stroke', edge => EDGE_STYLES[edge.edge_type]?.color ?? 'rgba(255,255,255,0.14)')
|
| 101 |
+
.attr('stroke-width', edge => EDGE_STYLES[edge.edge_type]?.width ?? 1)
|
| 102 |
+
.attr('stroke-dasharray', edge => EDGE_STYLES[edge.edge_type]?.dashArray ?? 'none')
|
| 103 |
+
.attr('stroke-opacity', 0.82);
|
| 104 |
+
|
| 105 |
+
const nodeEl = nodeGroup
|
| 106 |
+
.selectAll<SVGGElement, GraphNode>('g.node')
|
| 107 |
+
.data(simNodes, node => node.chunk_id)
|
| 108 |
+
.join('g')
|
| 109 |
+
.attr('class', 'node')
|
| 110 |
+
.style('cursor', 'pointer')
|
| 111 |
+
.call(
|
| 112 |
+
d3
|
| 113 |
+
.drag<SVGGElement, GraphNode>()
|
| 114 |
+
.on('start', (event: d3.D3DragEvent<SVGGElement, GraphNode, GraphNode>, node: GraphNode) => {
|
| 115 |
+
if (!event.active) {
|
| 116 |
+
sim.alphaTarget(0.3).restart();
|
| 117 |
+
}
|
| 118 |
+
node.fx = node.x;
|
| 119 |
+
node.fy = node.y;
|
| 120 |
+
})
|
| 121 |
+
.on('drag', (event: d3.D3DragEvent<SVGGElement, GraphNode, GraphNode>, node: GraphNode) => {
|
| 122 |
+
node.fx = event.x;
|
| 123 |
+
node.fy = event.y;
|
| 124 |
+
})
|
| 125 |
+
.on('end', (event: d3.D3DragEvent<SVGGElement, GraphNode, GraphNode>, node: GraphNode) => {
|
| 126 |
+
if (!event.active) {
|
| 127 |
+
sim.alphaTarget(0);
|
| 128 |
+
}
|
| 129 |
+
node.fx = null;
|
| 130 |
+
node.fy = null;
|
| 131 |
+
}),
|
| 132 |
+
);
|
| 133 |
+
|
| 134 |
+
nodeEl
|
| 135 |
+
.append('circle')
|
| 136 |
+
.attr('class', 'glow-ring')
|
| 137 |
+
.attr('r', node => getRadius(node) + 4)
|
| 138 |
+
.attr('fill', 'none')
|
| 139 |
+
.attr('stroke', node => JURISDICTION_COLORS[node.jurisdiction] ?? '#888')
|
| 140 |
+
.attr('stroke-width', 1.5)
|
| 141 |
+
.attr('filter', 'url(#node-glow)')
|
| 142 |
+
.attr('opacity', 0);
|
| 143 |
+
|
| 144 |
+
nodeEl
|
| 145 |
+
.append('circle')
|
| 146 |
+
.attr('class', 'main-circle')
|
| 147 |
+
.attr('r', node => getRadius(node))
|
| 148 |
+
.attr('fill', node => JURISDICTION_COLORS[node.jurisdiction] ?? '#888')
|
| 149 |
+
.attr('stroke', '#0d0d0d')
|
| 150 |
+
.attr('stroke-width', 1.25)
|
| 151 |
+
.attr('stroke-opacity', 0.75);
|
| 152 |
+
|
| 153 |
+
nodeEl
|
| 154 |
+
.append('text')
|
| 155 |
+
.attr('class', 'node-label')
|
| 156 |
+
.attr('dy', node => -(getRadius(node) + 6))
|
| 157 |
+
.attr('text-anchor', 'middle')
|
| 158 |
+
.attr('fill', 'rgba(229,226,225,0.86)')
|
| 159 |
+
.attr('font-size', '10px')
|
| 160 |
+
.attr('font-family', 'var(--font-inter), sans-serif')
|
| 161 |
+
.attr('pointer-events', 'none')
|
| 162 |
+
.text(node => `Sec ${node.section_id} / ${node.title.slice(0, 28)}${node.title.length > 28 ? '...' : ''}`)
|
| 163 |
+
.attr('opacity', 0);
|
| 164 |
+
|
| 165 |
+
nodeEl
|
| 166 |
+
.on('mouseenter', (_event: MouseEvent, node: GraphNode) => {
|
| 167 |
+
onNodeHover(chunkIdToNode.get(node.chunk_id) ?? null);
|
| 168 |
+
})
|
| 169 |
+
.on('mouseleave', () => {
|
| 170 |
+
onNodeHover(null);
|
| 171 |
+
})
|
| 172 |
+
.on('click', (event: MouseEvent, node: GraphNode) => {
|
| 173 |
+
event.stopPropagation();
|
| 174 |
+
onNodeClick(chunkIdToNode.get(node.chunk_id) ?? node);
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
svg.on('click', () => onNodeClick(null));
|
| 178 |
+
|
| 179 |
+
const zoom = d3
|
| 180 |
+
.zoom<SVGSVGElement, unknown>()
|
| 181 |
+
.scaleExtent([0.3, 5])
|
| 182 |
+
.on('zoom', (event: d3.D3ZoomEvent<SVGSVGElement, unknown>) => {
|
| 183 |
+
container.attr('transform', event.transform.toString());
|
| 184 |
+
nodeEl.select<SVGTextElement>('.node-label').attr('opacity', event.transform.k > 2.5 ? 0.9 : 0);
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
zoomRef.current = zoom;
|
| 188 |
+
svg.call(zoom);
|
| 189 |
+
|
| 190 |
+
sim.on('tick', () => {
|
| 191 |
+
link
|
| 192 |
+
.attr('x1', edge => (edge.source as GraphNode).x ?? 0)
|
| 193 |
+
.attr('y1', edge => (edge.source as GraphNode).y ?? 0)
|
| 194 |
+
.attr('x2', edge => (edge.target as GraphNode).x ?? 0)
|
| 195 |
+
.attr('y2', edge => (edge.target as GraphNode).y ?? 0);
|
| 196 |
+
|
| 197 |
+
nodeEl.attr('transform', node => `translate(${node.x ?? 0},${node.y ?? 0})`);
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
return () => {
|
| 201 |
+
sim.stop();
|
| 202 |
+
svg.on('.zoom', null);
|
| 203 |
+
};
|
| 204 |
+
// Rebuilding here keeps D3 ownership simple when the filter set changes.
|
| 205 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 206 |
+
}, [filteredNodes.length, filteredEdges.length, width, height, filters.nodeSizeMode]);
|
| 207 |
+
|
| 208 |
+
useEffect(() => {
|
| 209 |
+
if (!svgRef.current) {
|
| 210 |
+
return;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
const svg = d3.select(svgRef.current);
|
| 214 |
+
const nodeEl = svg.selectAll<SVGGElement, GraphNode>('g.node');
|
| 215 |
+
|
| 216 |
+
if (!hoveredNode) {
|
| 217 |
+
nodeEl.selectAll('.main-circle').attr('opacity', 1);
|
| 218 |
+
nodeEl.selectAll<SVGTextElement, GraphNode>('.node-label').each(function() {
|
| 219 |
+
const zoom = zoomRef.current;
|
| 220 |
+
if (zoom && svgRef.current) {
|
| 221 |
+
const transform = d3.zoomTransform(svgRef.current);
|
| 222 |
+
d3.select(this).attr('opacity', transform.k > 2.5 ? 0.9 : 0);
|
| 223 |
+
} else {
|
| 224 |
+
d3.select(this).attr('opacity', 0);
|
| 225 |
+
}
|
| 226 |
+
});
|
| 227 |
+
return;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
const neighborIds = new Set<string>([hoveredNode.chunk_id]);
|
| 231 |
+
svg.selectAll<SVGLineElement, GraphEdge>('line').each((edge: GraphEdge) => {
|
| 232 |
+
const src = typeof edge.source === 'string' ? edge.source : edge.source.chunk_id;
|
| 233 |
+
const tgt = typeof edge.target === 'string' ? edge.target : edge.target.chunk_id;
|
| 234 |
+
if (src === hoveredNode.chunk_id) {
|
| 235 |
+
neighborIds.add(tgt);
|
| 236 |
+
}
|
| 237 |
+
if (tgt === hoveredNode.chunk_id) {
|
| 238 |
+
neighborIds.add(src);
|
| 239 |
+
}
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
nodeEl.each(function(this: SVGGElement, node: GraphNode) {
|
| 243 |
+
const isNeighbor = neighborIds.has(node.chunk_id);
|
| 244 |
+
d3.select(this).select('.main-circle').attr('opacity', isNeighbor ? 1 : 0.12);
|
| 245 |
+
d3.select(this)
|
| 246 |
+
.select('.node-label')
|
| 247 |
+
.attr('opacity', node.chunk_id === hoveredNode.chunk_id ? 1 : 0);
|
| 248 |
+
});
|
| 249 |
+
}, [hoveredNode]);
|
| 250 |
+
|
| 251 |
+
useEffect(() => {
|
| 252 |
+
if (!svgRef.current) {
|
| 253 |
+
return;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
const svg = d3.select(svgRef.current);
|
| 257 |
+
svg.selectAll<SVGCircleElement, GraphNode>('.glow-ring').each(function(this: SVGCircleElement, node: GraphNode) {
|
| 258 |
+
const isSelected = selectedNode?.chunk_id === node.chunk_id;
|
| 259 |
+
d3.select(this)
|
| 260 |
+
.attr('opacity', isSelected ? 0.8 : 0)
|
| 261 |
+
.classed('node-selected', isSelected);
|
| 262 |
+
});
|
| 263 |
+
}, [selectedNode]);
|
| 264 |
+
|
| 265 |
+
return (
|
| 266 |
+
<svg
|
| 267 |
+
ref={svgRef}
|
| 268 |
+
width={width}
|
| 269 |
+
height={height}
|
| 270 |
+
className="h-full w-full bg-[#0d0d0d]"
|
| 271 |
+
style={{ display: 'block' }}
|
| 272 |
+
/>
|
| 273 |
+
);
|
| 274 |
+
}
|
frontend/src/components/graph/GraphExplorer.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef, useState } from 'react';
|
| 4 |
+
import { ForceGraph } from './ForceGraph';
|
| 5 |
+
import { GraphFilterSidebar } from './GraphFilterSidebar';
|
| 6 |
+
import { SectionDrawer } from './SectionDrawer';
|
| 7 |
+
import type { UseGraphExplorerReturn } from '@/hooks/useGraphExplorer';
|
| 8 |
+
|
| 9 |
+
interface Props extends UseGraphExplorerReturn {
|
| 10 |
+
onChatAboutSection: (
|
| 11 |
+
sectionId: string,
|
| 12 |
+
title: string,
|
| 13 |
+
docName: string,
|
| 14 |
+
jurisdiction: string,
|
| 15 |
+
) => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function GraphExplorer({
|
| 19 |
+
topology,
|
| 20 |
+
isLoading,
|
| 21 |
+
error,
|
| 22 |
+
selectedNode,
|
| 23 |
+
hoveredNode,
|
| 24 |
+
sectionContent,
|
| 25 |
+
isSectionLoading,
|
| 26 |
+
filters,
|
| 27 |
+
setSelectedNode,
|
| 28 |
+
setHoveredNode,
|
| 29 |
+
setFilters,
|
| 30 |
+
navigateToNode,
|
| 31 |
+
onChatAboutSection,
|
| 32 |
+
}: Props) {
|
| 33 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 34 |
+
const [dims, setDims] = useState({ width: 0, height: 0 });
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (!containerRef.current) {
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const ro = new ResizeObserver(entries => {
|
| 42 |
+
for (const entry of entries) {
|
| 43 |
+
const { width, height } = entry.contentRect;
|
| 44 |
+
setDims({ width: Math.floor(width), height: Math.floor(height) });
|
| 45 |
+
}
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
ro.observe(containerRef.current);
|
| 49 |
+
return () => ro.disconnect();
|
| 50 |
+
}, []);
|
| 51 |
+
|
| 52 |
+
const nodes = topology?.nodes ?? [];
|
| 53 |
+
const edges = topology?.edges ?? [];
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="flex h-full min-h-0 flex-col bg-[#0d0d0d]">
|
| 57 |
+
<div className="flex h-12 shrink-0 items-center justify-between border-b border-white/[0.06] px-4">
|
| 58 |
+
<div>
|
| 59 |
+
<p className="font-mono text-[10px] uppercase tracking-[0.24em] text-white/30">Graph Explorer</p>
|
| 60 |
+
<p className="mt-0.5 text-[12px] text-white/40">
|
| 61 |
+
{nodes.length} sections / {edges.length} relationships
|
| 62 |
+
</p>
|
| 63 |
+
</div>
|
| 64 |
+
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.2em] text-white/30">
|
| 65 |
+
<span className="h-1.5 w-1.5 rounded-full bg-[#4f98a3]" />
|
| 66 |
+
Live topology
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div ref={containerRef} className="relative min-h-0 flex-1 overflow-hidden bg-[#0d0d0d]">
|
| 71 |
+
<div className="pointer-events-none absolute inset-0 opacity-[0.14] [background-image:radial-gradient(circle_at_center,rgba(229,226,225,0.72)_1px,transparent_1.5px)] [background-position:0_0] [background-size:24px_24px]" />
|
| 72 |
+
|
| 73 |
+
{isLoading ? (
|
| 74 |
+
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
| 75 |
+
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.24em] text-white/30">
|
| 76 |
+
{[0, 1, 2].map(i => (
|
| 77 |
+
<span
|
| 78 |
+
key={i}
|
| 79 |
+
className="h-1.5 w-1.5 rounded-full bg-white/25 animate-pulse"
|
| 80 |
+
style={{ animationDelay: `${i * 140}ms` }}
|
| 81 |
+
/>
|
| 82 |
+
))}
|
| 83 |
+
Loading graph
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
) : null}
|
| 87 |
+
|
| 88 |
+
{error ? (
|
| 89 |
+
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
| 90 |
+
<div className="max-w-xs border border-red-300/15 bg-red-950/10 p-4 text-center">
|
| 91 |
+
<p className="text-sm text-red-200/80">Graph unavailable</p>
|
| 92 |
+
<p className="mt-1 text-xs leading-5 text-white/30">{error}</p>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
) : null}
|
| 96 |
+
|
| 97 |
+
{!isLoading && !error && nodes.length === 0 ? (
|
| 98 |
+
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
| 99 |
+
<div className="border border-white/[0.06] bg-[#141414] p-5 text-center">
|
| 100 |
+
<p className="text-sm text-white/60">No section relationships found</p>
|
| 101 |
+
<p className="mt-1 text-xs text-white/30">Run ingestion to populate the graph.</p>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
) : null}
|
| 105 |
+
|
| 106 |
+
{dims.width > 0 && dims.height > 0 && nodes.length > 0 ? (
|
| 107 |
+
<ForceGraph
|
| 108 |
+
nodes={nodes}
|
| 109 |
+
edges={edges}
|
| 110 |
+
filters={filters}
|
| 111 |
+
selectedNode={selectedNode}
|
| 112 |
+
hoveredNode={hoveredNode}
|
| 113 |
+
onNodeClick={node => setSelectedNode(node)}
|
| 114 |
+
onNodeHover={setHoveredNode}
|
| 115 |
+
width={dims.width}
|
| 116 |
+
height={dims.height}
|
| 117 |
+
/>
|
| 118 |
+
) : null}
|
| 119 |
+
|
| 120 |
+
<GraphFilterSidebar filters={filters} onFiltersChange={setFilters} topology={topology} />
|
| 121 |
+
|
| 122 |
+
<SectionDrawer
|
| 123 |
+
content={sectionContent}
|
| 124 |
+
isLoading={isSectionLoading}
|
| 125 |
+
onClose={() => setSelectedNode(null)}
|
| 126 |
+
onNodeNavigate={navigateToNode}
|
| 127 |
+
onChatAboutSection={onChatAboutSection}
|
| 128 |
+
/>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
);
|
| 132 |
+
}
|
frontend/src/components/graph/GraphFilterSidebar.tsx
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react';
|
| 4 |
+
import { ALL_JURISDICTIONS, JURISDICTION_LABELS } from '@/lib/constants';
|
| 5 |
+
import type { GraphFilters, GraphTopology } from '@/lib/types';
|
| 6 |
+
|
| 7 |
+
interface Props {
|
| 8 |
+
filters: GraphFilters;
|
| 9 |
+
onFiltersChange: (f: GraphFilters) => void;
|
| 10 |
+
topology: GraphTopology | null;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const JURISDICTION_DOT_CLASSES = {
|
| 14 |
+
CENTRAL: {
|
| 15 |
+
on: 'border-[#2db7a3] bg-[#2db7a3] opacity-100',
|
| 16 |
+
off: 'border-[#2db7a3] bg-transparent opacity-50',
|
| 17 |
+
},
|
| 18 |
+
MAHARASHTRA: {
|
| 19 |
+
on: 'border-[#4e7cff] bg-[#4e7cff] opacity-100',
|
| 20 |
+
off: 'border-[#4e7cff] bg-transparent opacity-50',
|
| 21 |
+
},
|
| 22 |
+
UTTAR_PRADESH: {
|
| 23 |
+
on: 'border-[#f4b63f] bg-[#f4b63f] opacity-100',
|
| 24 |
+
off: 'border-[#f4b63f] bg-transparent opacity-50',
|
| 25 |
+
},
|
| 26 |
+
KARNATAKA: {
|
| 27 |
+
on: 'border-[#78b94d] bg-[#78b94d] opacity-100',
|
| 28 |
+
off: 'border-[#78b94d] bg-transparent opacity-50',
|
| 29 |
+
},
|
| 30 |
+
TAMIL_NADU: {
|
| 31 |
+
on: 'border-[#ff7a59] bg-[#ff7a59] opacity-100',
|
| 32 |
+
off: 'border-[#ff7a59] bg-transparent opacity-50',
|
| 33 |
+
},
|
| 34 |
+
} as const;
|
| 35 |
+
|
| 36 |
+
export function GraphFilterSidebar({ filters, onFiltersChange, topology }: Props) {
|
| 37 |
+
const [collapsed, setCollapsed] = useState(false);
|
| 38 |
+
|
| 39 |
+
function toggleJurisdiction(j: (typeof ALL_JURISDICTIONS)[number]) {
|
| 40 |
+
const next = new Set(filters.jurisdictions);
|
| 41 |
+
if (next.has(j)) {
|
| 42 |
+
next.delete(j);
|
| 43 |
+
} else {
|
| 44 |
+
next.add(j);
|
| 45 |
+
}
|
| 46 |
+
onFiltersChange({ ...filters, jurisdictions: next });
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function toggleEdgeType(t: 'REFERENCES' | 'DERIVED_FROM') {
|
| 50 |
+
const next = new Set(filters.edgeTypes);
|
| 51 |
+
if (next.has(t)) {
|
| 52 |
+
next.delete(t);
|
| 53 |
+
} else {
|
| 54 |
+
next.add(t);
|
| 55 |
+
}
|
| 56 |
+
onFiltersChange({ ...filters, edgeTypes: next });
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<div className="absolute left-3 top-3 z-10 w-48 border border-white/[0.07] bg-[#141414]/95 text-xs text-white/60 shadow-[0_18px_60px_rgba(0,0,0,0.32)] backdrop-blur">
|
| 61 |
+
<button
|
| 62 |
+
className="flex w-full items-center gap-2 px-3 py-2.5 text-left font-mono text-[10px] uppercase tracking-[0.22em] text-white/40 transition-colors hover:text-white/70 active:scale-[0.99]"
|
| 63 |
+
onClick={() => setCollapsed(value => !value)}
|
| 64 |
+
aria-label="Toggle graph filters"
|
| 65 |
+
type="button"
|
| 66 |
+
>
|
| 67 |
+
<span className="h-1.5 w-1.5 rounded-full bg-[#4f98a3]" />
|
| 68 |
+
Filters
|
| 69 |
+
<span className={`ml-auto transition-transform duration-150 ease-out ${collapsed ? '-rotate-90' : ''}`}>
|
| 70 |
+
▼
|
| 71 |
+
</span>
|
| 72 |
+
</button>
|
| 73 |
+
|
| 74 |
+
{!collapsed ? (
|
| 75 |
+
<div className="space-y-4 border-t border-white/[0.05] px-3 pb-3 pt-3">
|
| 76 |
+
<section>
|
| 77 |
+
<p className="mb-2 font-mono text-[9px] uppercase tracking-[0.22em] text-white/30">Jurisdictions</p>
|
| 78 |
+
<div className="space-y-1">
|
| 79 |
+
{ALL_JURISDICTIONS.map(j => (
|
| 80 |
+
<label
|
| 81 |
+
key={j}
|
| 82 |
+
className="flex cursor-pointer items-center gap-2 py-0.5 transition-colors hover:text-white/80"
|
| 83 |
+
>
|
| 84 |
+
<input
|
| 85 |
+
type="checkbox"
|
| 86 |
+
checked={filters.jurisdictions.has(j)}
|
| 87 |
+
onChange={() => toggleJurisdiction(j)}
|
| 88 |
+
className="sr-only"
|
| 89 |
+
/>
|
| 90 |
+
<span
|
| 91 |
+
className={`h-2.5 w-2.5 shrink-0 rounded-full border transition-[background-color,opacity] duration-150 ease-out ${
|
| 92 |
+
filters.jurisdictions.has(j) ? JURISDICTION_DOT_CLASSES[j].on : JURISDICTION_DOT_CLASSES[j].off
|
| 93 |
+
}`}
|
| 94 |
+
/>
|
| 95 |
+
<span>{JURISDICTION_LABELS[j]}</span>
|
| 96 |
+
</label>
|
| 97 |
+
))}
|
| 98 |
+
</div>
|
| 99 |
+
</section>
|
| 100 |
+
|
| 101 |
+
<section>
|
| 102 |
+
<p className="mb-2 font-mono text-[9px] uppercase tracking-[0.22em] text-white/30">Relationship</p>
|
| 103 |
+
{(['REFERENCES', 'DERIVED_FROM'] as const).map(t => (
|
| 104 |
+
<label
|
| 105 |
+
key={t}
|
| 106 |
+
className="flex cursor-pointer items-center gap-2 py-0.5 transition-colors hover:text-white/80"
|
| 107 |
+
>
|
| 108 |
+
<input
|
| 109 |
+
type="checkbox"
|
| 110 |
+
checked={filters.edgeTypes.has(t)}
|
| 111 |
+
onChange={() => toggleEdgeType(t)}
|
| 112 |
+
className="sr-only"
|
| 113 |
+
/>
|
| 114 |
+
<span
|
| 115 |
+
className={`h-px w-4 shrink-0 transition-opacity ${
|
| 116 |
+
filters.edgeTypes.has(t) ? 'opacity-100' : 'opacity-25'
|
| 117 |
+
} ${
|
| 118 |
+
t === 'REFERENCES'
|
| 119 |
+
? 'bg-white/40'
|
| 120 |
+
: 'border-t border-dashed border-[#e8af34]/80 bg-transparent'
|
| 121 |
+
}`}
|
| 122 |
+
/>
|
| 123 |
+
<span>{t === 'REFERENCES' ? 'References' : 'Derived from'}</span>
|
| 124 |
+
</label>
|
| 125 |
+
))}
|
| 126 |
+
</section>
|
| 127 |
+
|
| 128 |
+
<section>
|
| 129 |
+
<p className="mb-2 font-mono text-[9px] uppercase tracking-[0.22em] text-white/30">Node scale</p>
|
| 130 |
+
{(['connections', 'uniform'] as const).map(mode => (
|
| 131 |
+
<label
|
| 132 |
+
key={mode}
|
| 133 |
+
className="flex cursor-pointer items-center gap-2 py-0.5 transition-colors hover:text-white/80"
|
| 134 |
+
>
|
| 135 |
+
<input
|
| 136 |
+
type="radio"
|
| 137 |
+
name="nodeSizeMode"
|
| 138 |
+
checked={filters.nodeSizeMode === mode}
|
| 139 |
+
onChange={() => onFiltersChange({ ...filters, nodeSizeMode: mode })}
|
| 140 |
+
className="sr-only"
|
| 141 |
+
/>
|
| 142 |
+
<span
|
| 143 |
+
className={`h-2.5 w-2.5 shrink-0 rounded-full border border-white/40 transition-colors duration-150 ease-out ${
|
| 144 |
+
filters.nodeSizeMode === mode ? 'bg-white/90' : 'bg-transparent'
|
| 145 |
+
}`}
|
| 146 |
+
/>
|
| 147 |
+
<span>{mode === 'connections' ? 'By connections' : 'Uniform'}</span>
|
| 148 |
+
</label>
|
| 149 |
+
))}
|
| 150 |
+
</section>
|
| 151 |
+
</div>
|
| 152 |
+
) : null}
|
| 153 |
+
</div>
|
| 154 |
+
);
|
| 155 |
+
}
|
frontend/src/components/graph/NodeInfoCard.tsx
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { JURISDICTION_COLORS, JURISDICTION_LABELS } from '@/lib/constants';
|
| 4 |
+
import type { GraphNode, SectionContent } from '@/lib/types';
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
node: GraphNode;
|
| 8 |
+
sectionContent: SectionContent | null;
|
| 9 |
+
isSectionLoading: boolean;
|
| 10 |
+
onChatAboutSection: (sectionId: string, title: string, docName: string, jurisdiction: string) => void;
|
| 11 |
+
onClose: () => void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function NodeInfoCard({
|
| 15 |
+
node,
|
| 16 |
+
sectionContent,
|
| 17 |
+
isSectionLoading,
|
| 18 |
+
onChatAboutSection,
|
| 19 |
+
onClose,
|
| 20 |
+
}: Props) {
|
| 21 |
+
const color = JURISDICTION_COLORS[node.jurisdiction] ?? '#888';
|
| 22 |
+
const refsOut = sectionContent?.connected_sections.filter(s => s.edge_type === 'REFERENCES_OUT') ?? [];
|
| 23 |
+
const refsIn = sectionContent?.connected_sections.filter(s => s.edge_type === 'REFERENCES_IN') ?? [];
|
| 24 |
+
const derivedOut = sectionContent?.connected_sections.filter(s => s.edge_type === 'DERIVED_FROM_OUT') ?? [];
|
| 25 |
+
const derivedIn = sectionContent?.connected_sections.filter(s => s.edge_type === 'DERIVED_FROM_IN') ?? [];
|
| 26 |
+
const hasRelationships = refsOut.length > 0 || refsIn.length > 0 || derivedOut.length > 0 || derivedIn.length > 0;
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<aside className="absolute right-3 top-3 z-20 w-72 border border-white/[0.07] bg-[#141414]/95 text-xs text-white/60 shadow-[0_22px_70px_rgba(0,0,0,0.36)] backdrop-blur">
|
| 30 |
+
<div className="border-b border-white/[0.06] px-3 py-3">
|
| 31 |
+
<div className="flex items-start justify-between gap-3">
|
| 32 |
+
<div className="min-w-0 flex-1">
|
| 33 |
+
<div className="mb-1 flex items-center gap-2">
|
| 34 |
+
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-white/30">Sec</span>
|
| 35 |
+
<span className="truncate text-[13px] font-semibold text-white/90">{node.section_id}</span>
|
| 36 |
+
<span
|
| 37 |
+
className="shrink-0 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em]"
|
| 38 |
+
style={{ backgroundColor: `${color}24`, color }}
|
| 39 |
+
>
|
| 40 |
+
{JURISDICTION_LABELS[node.jurisdiction] ?? node.jurisdiction}
|
| 41 |
+
</span>
|
| 42 |
+
</div>
|
| 43 |
+
<p className="line-clamp-2 text-[12px] leading-5 text-white/70">{node.title}</p>
|
| 44 |
+
<p className="mt-1 truncate font-mono text-[9px] uppercase tracking-[0.14em] text-white/30">
|
| 45 |
+
{node.doc_name}
|
| 46 |
+
</p>
|
| 47 |
+
</div>
|
| 48 |
+
<button
|
| 49 |
+
onClick={onClose}
|
| 50 |
+
className="text-white/30 transition-colors hover:text-white/70 active:scale-95"
|
| 51 |
+
aria-label="Close section summary"
|
| 52 |
+
type="button"
|
| 53 |
+
>
|
| 54 |
+
x
|
| 55 |
+
</button>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div className="grid grid-cols-2 border-b border-white/[0.05]">
|
| 60 |
+
<div className="border-r border-white/[0.05] px-3 py-2">
|
| 61 |
+
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-white/25">Links</p>
|
| 62 |
+
<p className="mt-0.5 text-sm text-white/75">{node.connection_count}</p>
|
| 63 |
+
</div>
|
| 64 |
+
<div className="px-3 py-2">
|
| 65 |
+
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-white/25">State</p>
|
| 66 |
+
<p className="mt-0.5 text-sm text-white/75">{node.is_active ? 'Active' : 'Archived'}</p>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div className="ledger-scroll max-h-40 space-y-2 overflow-y-auto px-3 py-3">
|
| 71 |
+
{isSectionLoading ? (
|
| 72 |
+
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-white/30">Loading relationships</p>
|
| 73 |
+
) : null}
|
| 74 |
+
|
| 75 |
+
{!isSectionLoading && refsOut.length > 0 ? (
|
| 76 |
+
<RelationshipGroup label="References" values={refsOut.map(s => s.section_id)} />
|
| 77 |
+
) : null}
|
| 78 |
+
{!isSectionLoading && refsIn.length > 0 ? (
|
| 79 |
+
<RelationshipGroup label="Referenced by" values={refsIn.map(s => s.section_id)} />
|
| 80 |
+
) : null}
|
| 81 |
+
{!isSectionLoading && derivedOut.length > 0 ? (
|
| 82 |
+
<RelationshipGroup label="Derives from" values={derivedOut.map(s => s.section_id)} />
|
| 83 |
+
) : null}
|
| 84 |
+
{!isSectionLoading && derivedIn.length > 0 ? (
|
| 85 |
+
<RelationshipGroup label="Derived by" values={derivedIn.map(s => s.section_id)} />
|
| 86 |
+
) : null}
|
| 87 |
+
{!isSectionLoading && !hasRelationships ? (
|
| 88 |
+
<p className="text-[11px] leading-5 text-white/25">No relationship detail loaded for this node.</p>
|
| 89 |
+
) : null}
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div className="border-t border-white/[0.05] px-3 py-3">
|
| 93 |
+
<button
|
| 94 |
+
onClick={() => onChatAboutSection(node.section_id, node.title, node.doc_name, node.jurisdiction)}
|
| 95 |
+
className="w-full border border-white/[0.09] bg-white/[0.03] px-3 py-2 text-[11px] font-medium text-white/70 transition-[background-color,border-color,transform] duration-150 ease-out hover:border-[#4f98a3]/50 hover:bg-white/[0.06] hover:text-white active:scale-[0.98]"
|
| 96 |
+
type="button"
|
| 97 |
+
>
|
| 98 |
+
Chat about this section
|
| 99 |
+
</button>
|
| 100 |
+
</div>
|
| 101 |
+
</aside>
|
| 102 |
+
);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function RelationshipGroup({ label, values }: { label: string; values: string[] }) {
|
| 106 |
+
return (
|
| 107 |
+
<div>
|
| 108 |
+
<p className="mb-1 font-mono text-[9px] uppercase tracking-[0.18em] text-white/25">{label}</p>
|
| 109 |
+
<p className="truncate text-[11px] leading-5 text-white/50">{values.map(value => `Sec ${value}`).join(', ')}</p>
|
| 110 |
+
</div>
|
| 111 |
+
);
|
| 112 |
+
}
|
frontend/src/components/graph/SectionDrawer.tsx
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { JURISDICTION_COLORS, JURISDICTION_LABELS } from '@/lib/constants';
|
| 4 |
+
import type { SectionContent } from '@/lib/types';
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
content: SectionContent | null;
|
| 8 |
+
isLoading: boolean;
|
| 9 |
+
onClose: () => void;
|
| 10 |
+
onNodeNavigate: (sectionId: string, jurisdiction: string) => void;
|
| 11 |
+
onChatAboutSection: (sectionId: string, title: string, docName: string, jurisdiction: string) => void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function SectionDrawer({
|
| 15 |
+
content,
|
| 16 |
+
isLoading,
|
| 17 |
+
onClose,
|
| 18 |
+
onNodeNavigate,
|
| 19 |
+
onChatAboutSection,
|
| 20 |
+
}: Props) {
|
| 21 |
+
const isOpen = isLoading || content !== null;
|
| 22 |
+
const color = content ? JURISDICTION_COLORS[content.jurisdiction] ?? '#888' : '#888';
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div
|
| 26 |
+
className={`absolute inset-x-3 bottom-3 z-20 flex max-h-[42%] min-h-0 flex-col overflow-hidden border border-white/[0.07] bg-[#141414]/95 shadow-[0_22px_70px_rgba(0,0,0,0.4)] backdrop-blur transition-[opacity,transform] duration-200 ease-out ${
|
| 27 |
+
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none translate-y-3 opacity-0'
|
| 28 |
+
}`}
|
| 29 |
+
aria-hidden={!isOpen}
|
| 30 |
+
>
|
| 31 |
+
{content ? (
|
| 32 |
+
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-white/[0.06] px-4 py-3">
|
| 33 |
+
<div className="min-w-0 flex-1">
|
| 34 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 35 |
+
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-white/30">Selected statute</span>
|
| 36 |
+
<span
|
| 37 |
+
className="px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em]"
|
| 38 |
+
style={{ backgroundColor: `${color}24`, color }}
|
| 39 |
+
>
|
| 40 |
+
{JURISDICTION_LABELS[content.jurisdiction] ?? content.jurisdiction}
|
| 41 |
+
</span>
|
| 42 |
+
{content.effective_date ? (
|
| 43 |
+
<span className="font-mono text-[9px] uppercase tracking-[0.14em] text-white/25">
|
| 44 |
+
Effective {content.effective_date}
|
| 45 |
+
</span>
|
| 46 |
+
) : null}
|
| 47 |
+
</div>
|
| 48 |
+
<p className="mt-1 truncate text-sm font-semibold text-white/90">
|
| 49 |
+
Sec {content.section_id} / {content.title}
|
| 50 |
+
</p>
|
| 51 |
+
<p className="mt-0.5 truncate text-[11px] text-white/30">{content.doc_name}</p>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div className="flex shrink-0 items-center gap-3">
|
| 55 |
+
{content.source_url ? (
|
| 56 |
+
<a
|
| 57 |
+
href={content.source_url}
|
| 58 |
+
target="_blank"
|
| 59 |
+
rel="noopener noreferrer"
|
| 60 |
+
className="font-mono text-[9px] uppercase tracking-[0.18em] text-white/30 transition-colors hover:text-[#4f98a3]"
|
| 61 |
+
>
|
| 62 |
+
View PDF
|
| 63 |
+
</a>
|
| 64 |
+
) : null}
|
| 65 |
+
<button
|
| 66 |
+
onClick={onClose}
|
| 67 |
+
className="text-white/30 transition-colors hover:text-white/70 active:scale-95"
|
| 68 |
+
aria-label="Close section drawer"
|
| 69 |
+
type="button"
|
| 70 |
+
>
|
| 71 |
+
x
|
| 72 |
+
</button>
|
| 73 |
+
</div>
|
| 74 |
+
</header>
|
| 75 |
+
) : null}
|
| 76 |
+
|
| 77 |
+
{isLoading ? (
|
| 78 |
+
<div className="flex flex-1 items-center justify-center">
|
| 79 |
+
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.24em] text-white/30">
|
| 80 |
+
{[0, 1, 2].map(i => (
|
| 81 |
+
<span
|
| 82 |
+
key={i}
|
| 83 |
+
className="h-1.5 w-1.5 rounded-full bg-white/20 animate-pulse"
|
| 84 |
+
style={{ animationDelay: `${i * 150}ms` }}
|
| 85 |
+
/>
|
| 86 |
+
))}
|
| 87 |
+
Loading section
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
) : null}
|
| 91 |
+
|
| 92 |
+
{!isLoading && content ? (
|
| 93 |
+
<>
|
| 94 |
+
<div className="ledger-scroll min-h-0 flex-1 overflow-y-auto px-4 py-3 text-[13px] leading-6 text-white/70">
|
| 95 |
+
{content.chunks.map((chunk, index) => (
|
| 96 |
+
<article key={chunk.chunk_id} className={index > 0 ? 'mt-4 border-t border-white/[0.05] pt-4' : ''}>
|
| 97 |
+
<p className="whitespace-pre-wrap">{chunk.text}</p>
|
| 98 |
+
</article>
|
| 99 |
+
))}
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<footer className="flex shrink-0 items-center gap-3 border-t border-white/[0.06] px-4 py-2.5">
|
| 103 |
+
<div className="ledger-scroll flex min-w-0 flex-1 gap-1.5 overflow-x-auto">
|
| 104 |
+
{content.connected_sections.slice(0, 10).map((section, index) => (
|
| 105 |
+
<button
|
| 106 |
+
key={`${section.section_id}-${index}`}
|
| 107 |
+
onClick={() => onNodeNavigate(section.section_id, section.jurisdiction)}
|
| 108 |
+
className="shrink-0 border border-white/[0.08] bg-white/[0.02] px-2 py-1 font-mono text-[9px] uppercase tracking-[0.14em] text-white/40 transition-[background-color,border-color,color,transform] duration-150 ease-out hover:border-white/20 hover:bg-white/[0.05] hover:text-white/75 active:scale-[0.98]"
|
| 109 |
+
type="button"
|
| 110 |
+
style={{
|
| 111 |
+
borderColor: section.edge_type.startsWith('DERIVED') ? 'rgba(232,175,52,0.28)' : undefined,
|
| 112 |
+
}}
|
| 113 |
+
>
|
| 114 |
+
Sec {section.section_id}
|
| 115 |
+
</button>
|
| 116 |
+
))}
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<button
|
| 120 |
+
onClick={() =>
|
| 121 |
+
onChatAboutSection(content.section_id, content.title, content.doc_name, content.jurisdiction)
|
| 122 |
+
}
|
| 123 |
+
className="shrink-0 border border-[#4f98a3]/35 bg-[#4f98a3]/10 px-3 py-1.5 text-[11px] font-medium text-[#9ed4dc] transition-[background-color,border-color,transform] duration-150 ease-out hover:border-[#4f98a3]/70 hover:bg-[#4f98a3]/16 active:scale-[0.98]"
|
| 124 |
+
type="button"
|
| 125 |
+
>
|
| 126 |
+
Chat about section
|
| 127 |
+
</button>
|
| 128 |
+
</footer>
|
| 129 |
+
</>
|
| 130 |
+
) : null}
|
| 131 |
+
</div>
|
| 132 |
+
);
|
| 133 |
+
}
|
frontend/src/hooks/useChat.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useCallback, useRef, useState } from 'react';
|
| 4 |
+
import { queryRera, querySectionContext } from '@/lib/api';
|
| 5 |
+
import type { ApiResponse, ChatMessage, Jurisdiction } from '@/lib/types';
|
| 6 |
+
|
| 7 |
+
const SESSION_KEY = 'civicsetu_session_id';
|
| 8 |
+
|
| 9 |
+
export interface UseChatReturn {
|
| 10 |
+
messages: ChatMessage[];
|
| 11 |
+
isLoading: boolean;
|
| 12 |
+
sendMessage: (text: string, jurisdiction: Jurisdiction | '') => Promise<void>;
|
| 13 |
+
sendSectionMessage: (text: string, sectionId: string, jurisdiction: string) => Promise<void>;
|
| 14 |
+
newConversation: () => void;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function useChat(): UseChatReturn {
|
| 18 |
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
| 19 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 20 |
+
const sessionIdRef = useRef<string | null>(
|
| 21 |
+
typeof window !== 'undefined' ? window.localStorage.getItem(SESSION_KEY) : null,
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
const _handleResponse = useCallback((data: ApiResponse) => {
|
| 25 |
+
if (data.session_id) {
|
| 26 |
+
sessionIdRef.current = data.session_id;
|
| 27 |
+
if (typeof window !== 'undefined') {
|
| 28 |
+
window.localStorage.setItem(SESSION_KEY, data.session_id);
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
setMessages(prev => [
|
| 32 |
+
...prev,
|
| 33 |
+
{ id: crypto.randomUUID(), role: 'assistant', text: data.answer, data },
|
| 34 |
+
]);
|
| 35 |
+
}, []);
|
| 36 |
+
|
| 37 |
+
const _handleError = useCallback((error: unknown) => {
|
| 38 |
+
setMessages(prev => [
|
| 39 |
+
...prev,
|
| 40 |
+
{
|
| 41 |
+
id: crypto.randomUUID(),
|
| 42 |
+
role: 'error',
|
| 43 |
+
text: error instanceof Error ? error.message : 'Request failed',
|
| 44 |
+
},
|
| 45 |
+
]);
|
| 46 |
+
}, []);
|
| 47 |
+
|
| 48 |
+
const sendMessage = useCallback(
|
| 49 |
+
async (text: string, jurisdiction: Jurisdiction | '') => {
|
| 50 |
+
setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'user', text }]);
|
| 51 |
+
setIsLoading(true);
|
| 52 |
+
try {
|
| 53 |
+
const data = await queryRera({
|
| 54 |
+
query: text,
|
| 55 |
+
top_k: 5,
|
| 56 |
+
...(jurisdiction ? { jurisdiction_filter: jurisdiction } : {}),
|
| 57 |
+
...(sessionIdRef.current ? { session_id: sessionIdRef.current } : {}),
|
| 58 |
+
});
|
| 59 |
+
_handleResponse(data);
|
| 60 |
+
} catch (error) {
|
| 61 |
+
_handleError(error);
|
| 62 |
+
} finally {
|
| 63 |
+
setIsLoading(false);
|
| 64 |
+
}
|
| 65 |
+
},
|
| 66 |
+
[_handleResponse, _handleError],
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
const sendSectionMessage = useCallback(
|
| 70 |
+
async (text: string, sectionId: string, jurisdiction: string) => {
|
| 71 |
+
setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'user', text }]);
|
| 72 |
+
setIsLoading(true);
|
| 73 |
+
try {
|
| 74 |
+
const data = await querySectionContext({
|
| 75 |
+
query: text,
|
| 76 |
+
section_id: sectionId,
|
| 77 |
+
jurisdiction,
|
| 78 |
+
...(sessionIdRef.current ? { session_id: sessionIdRef.current } : {}),
|
| 79 |
+
});
|
| 80 |
+
_handleResponse(data);
|
| 81 |
+
} catch (error) {
|
| 82 |
+
_handleError(error);
|
| 83 |
+
} finally {
|
| 84 |
+
setIsLoading(false);
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
[_handleResponse, _handleError],
|
| 88 |
+
);
|
| 89 |
+
|
| 90 |
+
const newConversation = useCallback(() => {
|
| 91 |
+
sessionIdRef.current = null;
|
| 92 |
+
if (typeof window !== 'undefined') {
|
| 93 |
+
window.localStorage.removeItem(SESSION_KEY);
|
| 94 |
+
}
|
| 95 |
+
setMessages([]);
|
| 96 |
+
}, []);
|
| 97 |
+
|
| 98 |
+
return { messages, isLoading, sendMessage, sendSectionMessage, newConversation };
|
| 99 |
+
}
|
frontend/src/hooks/useGraphExplorer.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useCallback, useEffect, useState } from 'react';
|
| 4 |
+
import { fetchGraphTopology, fetchSectionContent } from '@/lib/api';
|
| 5 |
+
import { ALL_JURISDICTIONS } from '@/lib/constants';
|
| 6 |
+
import type { GraphFilters, GraphNode, GraphTopology, SectionContent } from '@/lib/types';
|
| 7 |
+
|
| 8 |
+
export interface UseGraphExplorerReturn {
|
| 9 |
+
topology: GraphTopology | null;
|
| 10 |
+
isLoading: boolean;
|
| 11 |
+
error: string | null;
|
| 12 |
+
selectedNode: GraphNode | null;
|
| 13 |
+
hoveredNode: GraphNode | null;
|
| 14 |
+
sectionContent: SectionContent | null;
|
| 15 |
+
isSectionLoading: boolean;
|
| 16 |
+
filters: GraphFilters;
|
| 17 |
+
setSelectedNode: (node: GraphNode | null) => void;
|
| 18 |
+
setHoveredNode: (node: GraphNode | null) => void;
|
| 19 |
+
setFilters: (filters: GraphFilters) => void;
|
| 20 |
+
navigateToNode: (sectionId: string, jurisdiction: string) => void;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function useGraphExplorer(): UseGraphExplorerReturn {
|
| 24 |
+
const [topology, setTopology] = useState<GraphTopology | null>(null);
|
| 25 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 26 |
+
const [error, setError] = useState<string | null>(null);
|
| 27 |
+
|
| 28 |
+
const [selectedNode, setSelectedNodeRaw] = useState<GraphNode | null>(null);
|
| 29 |
+
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
| 30 |
+
const [sectionContent, setSectionContent] = useState<SectionContent | null>(null);
|
| 31 |
+
const [isSectionLoading, setIsSectionLoading] = useState(false);
|
| 32 |
+
|
| 33 |
+
const [filters, setFilters] = useState<GraphFilters>({
|
| 34 |
+
jurisdictions: new Set(ALL_JURISDICTIONS),
|
| 35 |
+
edgeTypes: new Set(['REFERENCES', 'DERIVED_FROM']),
|
| 36 |
+
nodeSizeMode: 'connections',
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
// Fetch topology once on mount
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
let cancelled = false;
|
| 42 |
+
setIsLoading(true);
|
| 43 |
+
fetchGraphTopology()
|
| 44 |
+
.then(data => {
|
| 45 |
+
if (!cancelled) {
|
| 46 |
+
setTopology(data);
|
| 47 |
+
setError(null);
|
| 48 |
+
}
|
| 49 |
+
})
|
| 50 |
+
.catch(err => {
|
| 51 |
+
if (!cancelled) setError((err as Error).message);
|
| 52 |
+
})
|
| 53 |
+
.finally(() => {
|
| 54 |
+
if (!cancelled) setIsLoading(false);
|
| 55 |
+
});
|
| 56 |
+
return () => { cancelled = true; };
|
| 57 |
+
}, []);
|
| 58 |
+
|
| 59 |
+
// Fetch section content when a node is selected
|
| 60 |
+
const setSelectedNode = useCallback((node: GraphNode | null) => {
|
| 61 |
+
setSelectedNodeRaw(node);
|
| 62 |
+
if (!node) {
|
| 63 |
+
setSectionContent(null);
|
| 64 |
+
return;
|
| 65 |
+
}
|
| 66 |
+
setIsSectionLoading(true);
|
| 67 |
+
fetchSectionContent(node.section_id, node.jurisdiction, node.chunk_id)
|
| 68 |
+
.then(data => setSectionContent(data))
|
| 69 |
+
.catch(err => {
|
| 70 |
+
console.error('Section content fetch failed:', err);
|
| 71 |
+
setSectionContent(null);
|
| 72 |
+
})
|
| 73 |
+
.finally(() => setIsSectionLoading(false));
|
| 74 |
+
}, []);
|
| 75 |
+
|
| 76 |
+
// Navigate to a node by section_id + jurisdiction (called from SectionDrawer chips)
|
| 77 |
+
const navigateToNode = useCallback(
|
| 78 |
+
(sectionId: string, jurisdiction: string) => {
|
| 79 |
+
if (!topology) return;
|
| 80 |
+
const target = topology.nodes.find(
|
| 81 |
+
n => n.section_id === sectionId && n.jurisdiction === jurisdiction,
|
| 82 |
+
);
|
| 83 |
+
if (target) setSelectedNode(target);
|
| 84 |
+
},
|
| 85 |
+
[topology, setSelectedNode],
|
| 86 |
+
);
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
topology,
|
| 90 |
+
isLoading,
|
| 91 |
+
error,
|
| 92 |
+
selectedNode,
|
| 93 |
+
hoveredNode,
|
| 94 |
+
sectionContent,
|
| 95 |
+
isSectionLoading,
|
| 96 |
+
filters,
|
| 97 |
+
setSelectedNode,
|
| 98 |
+
setHoveredNode,
|
| 99 |
+
setFilters,
|
| 100 |
+
navigateToNode,
|
| 101 |
+
};
|
| 102 |
+
}
|
frontend/src/lib/api.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
ApiResponse,
|
| 3 |
+
GraphTopology,
|
| 4 |
+
Jurisdiction,
|
| 5 |
+
SectionContent,
|
| 6 |
+
} from './types';
|
| 7 |
+
|
| 8 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? '';
|
| 9 |
+
|
| 10 |
+
function apiUrl(path: string): string {
|
| 11 |
+
return `${API_BASE_URL}${path}`;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export interface QueryPayload {
|
| 15 |
+
query: string;
|
| 16 |
+
session_id?: string;
|
| 17 |
+
jurisdiction_filter?: Jurisdiction;
|
| 18 |
+
top_k?: number;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export async function queryRera(payload: QueryPayload): Promise<ApiResponse> {
|
| 22 |
+
const res = await fetch(apiUrl('/api/v1/query'), {
|
| 23 |
+
method: 'POST',
|
| 24 |
+
headers: { 'Content-Type': 'application/json' },
|
| 25 |
+
body: JSON.stringify(payload),
|
| 26 |
+
});
|
| 27 |
+
if (!res.ok) {
|
| 28 |
+
let detail = res.statusText;
|
| 29 |
+
try {
|
| 30 |
+
const err = (await res.json()) as { detail?: string };
|
| 31 |
+
if (err.detail) detail = err.detail;
|
| 32 |
+
} catch {}
|
| 33 |
+
throw new Error(`API error ${res.status}: ${detail}`);
|
| 34 |
+
}
|
| 35 |
+
return res.json() as Promise<ApiResponse>;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export async function fetchGraphTopology(): Promise<GraphTopology> {
|
| 39 |
+
const res = await fetch(apiUrl('/api/v1/graph/topology'));
|
| 40 |
+
if (!res.ok) {
|
| 41 |
+
throw new Error(`Topology fetch failed: ${res.status}`);
|
| 42 |
+
}
|
| 43 |
+
return res.json() as Promise<GraphTopology>;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export async function fetchSectionContent(
|
| 47 |
+
sectionId: string,
|
| 48 |
+
jurisdiction: string,
|
| 49 |
+
chunkId?: string,
|
| 50 |
+
): Promise<SectionContent> {
|
| 51 |
+
const params = new URLSearchParams({ jurisdiction });
|
| 52 |
+
if (chunkId) {
|
| 53 |
+
params.set('chunk_id', chunkId);
|
| 54 |
+
}
|
| 55 |
+
const url = apiUrl(`/api/v1/graph/section/${encodeURIComponent(sectionId)}?${params.toString()}`);
|
| 56 |
+
const res = await fetch(url);
|
| 57 |
+
if (!res.ok) {
|
| 58 |
+
let detail = res.statusText;
|
| 59 |
+
try {
|
| 60 |
+
const err = (await res.json()) as { detail?: string };
|
| 61 |
+
if (err.detail) detail = err.detail;
|
| 62 |
+
} catch {}
|
| 63 |
+
throw new Error(`Section fetch failed: ${res.status} ${detail}`);
|
| 64 |
+
}
|
| 65 |
+
return res.json() as Promise<SectionContent>;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export interface SectionContextPayload {
|
| 69 |
+
query: string;
|
| 70 |
+
section_id: string;
|
| 71 |
+
jurisdiction: string;
|
| 72 |
+
session_id?: string;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export async function querySectionContext(
|
| 76 |
+
payload: SectionContextPayload,
|
| 77 |
+
): Promise<ApiResponse> {
|
| 78 |
+
const res = await fetch(apiUrl('/api/v1/query/section-context'), {
|
| 79 |
+
method: 'POST',
|
| 80 |
+
headers: { 'Content-Type': 'application/json' },
|
| 81 |
+
body: JSON.stringify(payload),
|
| 82 |
+
});
|
| 83 |
+
if (!res.ok) {
|
| 84 |
+
let detail = res.statusText;
|
| 85 |
+
try {
|
| 86 |
+
const err = (await res.json()) as { detail?: string };
|
| 87 |
+
if (err.detail) detail = err.detail;
|
| 88 |
+
} catch {}
|
| 89 |
+
throw new Error(`API error ${res.status}: ${detail}`);
|
| 90 |
+
}
|
| 91 |
+
return res.json() as Promise<ApiResponse>;
|
| 92 |
+
}
|
frontend/src/lib/constants.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Jurisdiction } from './types';
|
| 2 |
+
|
| 3 |
+
export const JURISDICTION_COLORS: Record<Jurisdiction, string> = {
|
| 4 |
+
CENTRAL: '#2db7a3',
|
| 5 |
+
MAHARASHTRA: '#4e7cff',
|
| 6 |
+
UTTAR_PRADESH: '#f4b63f',
|
| 7 |
+
KARNATAKA: '#78b94d',
|
| 8 |
+
TAMIL_NADU: '#ff7a59',
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export const EDGE_STYLES = {
|
| 12 |
+
REFERENCES: {
|
| 13 |
+
color: 'rgba(255,255,255,0.15)',
|
| 14 |
+
dashArray: 'none',
|
| 15 |
+
width: 1,
|
| 16 |
+
},
|
| 17 |
+
DERIVED_FROM: {
|
| 18 |
+
color: '#e8af34',
|
| 19 |
+
dashArray: '6,3',
|
| 20 |
+
width: 1.5,
|
| 21 |
+
},
|
| 22 |
+
} as const;
|
| 23 |
+
|
| 24 |
+
export const NODE_RADIUS = {
|
| 25 |
+
MIN: 4,
|
| 26 |
+
MAX: 18,
|
| 27 |
+
DEFAULT: 6,
|
| 28 |
+
} as const;
|
| 29 |
+
|
| 30 |
+
export const ALL_JURISDICTIONS: Jurisdiction[] = [
|
| 31 |
+
'CENTRAL',
|
| 32 |
+
'MAHARASHTRA',
|
| 33 |
+
'UTTAR_PRADESH',
|
| 34 |
+
'KARNATAKA',
|
| 35 |
+
'TAMIL_NADU',
|
| 36 |
+
];
|
| 37 |
+
|
| 38 |
+
export const JURISDICTION_LABELS: Record<Jurisdiction, string> = {
|
| 39 |
+
CENTRAL: 'Central',
|
| 40 |
+
MAHARASHTRA: 'Maharashtra',
|
| 41 |
+
UTTAR_PRADESH: 'Uttar Pradesh',
|
| 42 |
+
KARNATAKA: 'Karnataka',
|
| 43 |
+
TAMIL_NADU: 'Tamil Nadu',
|
| 44 |
+
};
|
frontend/src/lib/types.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type Jurisdiction =
|
| 2 |
+
| 'CENTRAL'
|
| 3 |
+
| 'MAHARASHTRA'
|
| 4 |
+
| 'UTTAR_PRADESH'
|
| 5 |
+
| 'KARNATAKA'
|
| 6 |
+
| 'TAMIL_NADU';
|
| 7 |
+
|
| 8 |
+
export type QueryType =
|
| 9 |
+
| 'fact_lookup'
|
| 10 |
+
| 'cross_reference'
|
| 11 |
+
| 'conflict_detection'
|
| 12 |
+
| 'temporal'
|
| 13 |
+
| 'penalty_lookup';
|
| 14 |
+
|
| 15 |
+
export interface Citation {
|
| 16 |
+
section_id: string;
|
| 17 |
+
doc_name: string;
|
| 18 |
+
jurisdiction: Jurisdiction;
|
| 19 |
+
effective_date: string | null;
|
| 20 |
+
source_url: string;
|
| 21 |
+
chunk_id: string;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export interface CivicSetuResponse {
|
| 25 |
+
answer: string;
|
| 26 |
+
citations: Citation[];
|
| 27 |
+
confidence_score: number;
|
| 28 |
+
query_type_resolved: QueryType;
|
| 29 |
+
conflict_warnings: string[];
|
| 30 |
+
amendment_notice: string | null;
|
| 31 |
+
session_id: string | null;
|
| 32 |
+
disclaimer: string;
|
| 33 |
+
confidence_level: 'HIGH' | 'MEDIUM' | 'LOW';
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export interface InsufficientInfoResponse {
|
| 37 |
+
answer: string;
|
| 38 |
+
searched_query: string;
|
| 39 |
+
session_id: string | null;
|
| 40 |
+
disclaimer: string;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export type ApiResponse = CivicSetuResponse | InsufficientInfoResponse;
|
| 44 |
+
|
| 45 |
+
export function isCivicSetuResponse(r: ApiResponse): r is CivicSetuResponse {
|
| 46 |
+
return 'citations' in r && Array.isArray(r.citations);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export interface ChatMessage {
|
| 50 |
+
id: string;
|
| 51 |
+
role: 'user' | 'assistant' | 'error';
|
| 52 |
+
text: string;
|
| 53 |
+
data?: ApiResponse;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// ── Graph Types ───────────────────────────────────────────────────────────────
|
| 57 |
+
|
| 58 |
+
export interface GraphNode {
|
| 59 |
+
chunk_id: string;
|
| 60 |
+
section_id: string;
|
| 61 |
+
title: string;
|
| 62 |
+
jurisdiction: Jurisdiction;
|
| 63 |
+
doc_name: string;
|
| 64 |
+
is_active: boolean;
|
| 65 |
+
connection_count: number;
|
| 66 |
+
// D3 simulation fields, mutated in place by force simulation.
|
| 67 |
+
x?: number;
|
| 68 |
+
y?: number;
|
| 69 |
+
vx?: number;
|
| 70 |
+
vy?: number;
|
| 71 |
+
fx?: number | null;
|
| 72 |
+
fy?: number | null;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export interface GraphEdge {
|
| 76 |
+
source: string | GraphNode;
|
| 77 |
+
target: string | GraphNode;
|
| 78 |
+
edge_type: 'REFERENCES' | 'DERIVED_FROM';
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
export interface GraphTopology {
|
| 82 |
+
nodes: GraphNode[];
|
| 83 |
+
edges: GraphEdge[];
|
| 84 |
+
stats: Record<string, number>;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
export interface SectionChunk {
|
| 88 |
+
chunk_id: string;
|
| 89 |
+
text: string;
|
| 90 |
+
page_number: number;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export interface ConnectedSection {
|
| 94 |
+
section_id: string;
|
| 95 |
+
title: string;
|
| 96 |
+
jurisdiction: Jurisdiction;
|
| 97 |
+
edge_type: 'REFERENCES_OUT' | 'REFERENCES_IN' | 'DERIVED_FROM_OUT' | 'DERIVED_FROM_IN';
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export interface SectionContent {
|
| 101 |
+
section_id: string;
|
| 102 |
+
title: string;
|
| 103 |
+
doc_name: string;
|
| 104 |
+
jurisdiction: Jurisdiction;
|
| 105 |
+
effective_date: string | null;
|
| 106 |
+
source_url: string;
|
| 107 |
+
chunks: SectionChunk[];
|
| 108 |
+
connected_sections: ConnectedSection[];
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export interface GraphFilters {
|
| 112 |
+
jurisdictions: Set<Jurisdiction>;
|
| 113 |
+
edgeTypes: Set<'REFERENCES' | 'DERIVED_FROM'>;
|
| 114 |
+
nodeSizeMode: 'connections' | 'uniform';
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
export interface SectionContext {
|
| 118 |
+
sectionId: string;
|
| 119 |
+
title: string;
|
| 120 |
+
docName: string;
|
| 121 |
+
jurisdiction: string;
|
| 122 |
+
}
|
frontend/tailwind.config.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from 'tailwindcss';
|
| 2 |
+
|
| 3 |
+
const config: Config = {
|
| 4 |
+
content: [
|
| 5 |
+
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
| 6 |
+
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
| 7 |
+
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
| 8 |
+
],
|
| 9 |
+
darkMode: 'class',
|
| 10 |
+
theme: {
|
| 11 |
+
extend: {
|
| 12 |
+
fontFamily: {
|
| 13 |
+
sans: ['var(--font-inter)', 'Inter', 'sans-serif'],
|
| 14 |
+
serif: ['var(--font-merriweather)', 'Merriweather', 'serif'],
|
| 15 |
+
},
|
| 16 |
+
colors: {
|
| 17 |
+
graph: {
|
| 18 |
+
bg: '#0d0d0d',
|
| 19 |
+
central: '#2db7a3',
|
| 20 |
+
maharashtra: '#4e7cff',
|
| 21 |
+
uttar_pradesh:'#f4b63f',
|
| 22 |
+
karnataka: '#78b94d',
|
| 23 |
+
tamil_nadu: '#ff7a59',
|
| 24 |
+
},
|
| 25 |
+
},
|
| 26 |
+
},
|
| 27 |
+
},
|
| 28 |
+
plugins: [],
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
export default config;
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": false,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "preserve",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [
|
| 17 |
+
{
|
| 18 |
+
"name": "next"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"paths": {
|
| 22 |
+
"@/*": ["./src/*"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
| 26 |
+
"exclude": ["node_modules"]
|
| 27 |
+
}
|
frontend/tsconfig.tsbuildinfo
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
pyproject.toml
CHANGED
|
@@ -10,6 +10,7 @@ requires-python = ">=3.11"
|
|
| 10 |
dependencies = [
|
| 11 |
# --- LLM & Orchestration ---
|
| 12 |
"langgraph>=0.3.0",
|
|
|
|
| 13 |
"langchain>=0.3.0",
|
| 14 |
"langchain-core>=0.3.0",
|
| 15 |
"langchain-google-genai>=2.0.0",
|
|
@@ -43,6 +44,7 @@ dependencies = [
|
|
| 43 |
# --- Utilities ---
|
| 44 |
"python-dotenv>=1.0.0",
|
| 45 |
"tenacity>=8.3.0",
|
|
|
|
| 46 |
"structlog>=24.0.0",
|
| 47 |
"einops>=0.8.2",
|
| 48 |
"huggingface-hub[cli]>=1.7.1",
|
|
|
|
| 10 |
dependencies = [
|
| 11 |
# --- LLM & Orchestration ---
|
| 12 |
"langgraph>=0.3.0",
|
| 13 |
+
"langgraph-checkpoint-postgres>=2.0.0",
|
| 14 |
"langchain>=0.3.0",
|
| 15 |
"langchain-core>=0.3.0",
|
| 16 |
"langchain-google-genai>=2.0.0",
|
|
|
|
| 44 |
# --- Utilities ---
|
| 45 |
"python-dotenv>=1.0.0",
|
| 46 |
"tenacity>=8.3.0",
|
| 47 |
+
"cachetools>=5.3.0",
|
| 48 |
"structlog>=24.0.0",
|
| 49 |
"einops>=0.8.2",
|
| 50 |
"huggingface-hub[cli]>=1.7.1",
|
src/civicsetu/agent/graph.py
CHANGED
|
@@ -1,17 +1,19 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import structlog
|
|
|
|
| 4 |
from langgraph.graph import END, StateGraph
|
| 5 |
|
| 6 |
from civicsetu.agent.edges import route_after_classifier, route_after_validator
|
| 7 |
from civicsetu.agent.nodes import (
|
| 8 |
classifier_node,
|
| 9 |
generator_node,
|
|
|
|
|
|
|
| 10 |
reranker_node,
|
|
|
|
| 11 |
validator_node,
|
| 12 |
vector_retrieval_node,
|
| 13 |
-
graph_retrieval_node,
|
| 14 |
-
hybrid_retrieval_node,
|
| 15 |
)
|
| 16 |
from civicsetu.agent.state import CivicSetuState
|
| 17 |
|
|
@@ -32,33 +34,31 @@ def _retry_node(state: CivicSetuState) -> dict:
|
|
| 32 |
def build_graph() -> StateGraph:
|
| 33 |
graph = StateGraph(CivicSetuState)
|
| 34 |
|
| 35 |
-
|
| 36 |
-
graph.add_node("classifier",
|
| 37 |
-
graph.add_node("vector_retrieval",
|
| 38 |
-
graph.add_node("graph_retrieval",
|
| 39 |
-
graph.add_node("reranker",
|
| 40 |
-
graph.add_node("generator",
|
| 41 |
-
graph.add_node("validator",
|
| 42 |
-
graph.add_node("retry",
|
| 43 |
-
graph.add_node("hybrid_retrieval",
|
| 44 |
-
|
| 45 |
-
# ── Entry point ────────────────────────────────────────────────────────────
|
| 46 |
-
graph.set_entry_point("classifier")
|
| 47 |
|
| 48 |
-
|
|
|
|
| 49 |
graph.add_conditional_edges(
|
| 50 |
"classifier",
|
| 51 |
route_after_classifier,
|
| 52 |
{
|
| 53 |
"vector_retrieval": "vector_retrieval",
|
| 54 |
-
"graph_retrieval":
|
| 55 |
"hybrid_retrieval": "hybrid_retrieval",
|
| 56 |
},
|
| 57 |
)
|
| 58 |
graph.add_edge("vector_retrieval", "reranker")
|
| 59 |
-
graph.add_edge("graph_retrieval",
|
| 60 |
-
graph.add_edge("reranker",
|
| 61 |
-
graph.add_edge("generator",
|
| 62 |
graph.add_conditional_edges(
|
| 63 |
"validator",
|
| 64 |
route_after_validator,
|
|
@@ -70,6 +70,6 @@ def build_graph() -> StateGraph:
|
|
| 70 |
return graph
|
| 71 |
|
| 72 |
|
| 73 |
-
def get_compiled_graph():
|
| 74 |
"""Returns the compiled, executable LangGraph. Call once, reuse."""
|
| 75 |
-
return build_graph().compile()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import structlog
|
| 4 |
+
from langgraph.checkpoint.base import BaseCheckpointSaver
|
| 5 |
from langgraph.graph import END, StateGraph
|
| 6 |
|
| 7 |
from civicsetu.agent.edges import route_after_classifier, route_after_validator
|
| 8 |
from civicsetu.agent.nodes import (
|
| 9 |
classifier_node,
|
| 10 |
generator_node,
|
| 11 |
+
graph_retrieval_node,
|
| 12 |
+
hybrid_retrieval_node,
|
| 13 |
reranker_node,
|
| 14 |
+
turn_reset_node,
|
| 15 |
validator_node,
|
| 16 |
vector_retrieval_node,
|
|
|
|
|
|
|
| 17 |
)
|
| 18 |
from civicsetu.agent.state import CivicSetuState
|
| 19 |
|
|
|
|
| 34 |
def build_graph() -> StateGraph:
|
| 35 |
graph = StateGraph(CivicSetuState)
|
| 36 |
|
| 37 |
+
graph.add_node("turn_reset", turn_reset_node)
|
| 38 |
+
graph.add_node("classifier", classifier_node)
|
| 39 |
+
graph.add_node("vector_retrieval", vector_retrieval_node)
|
| 40 |
+
graph.add_node("graph_retrieval", graph_retrieval_node)
|
| 41 |
+
graph.add_node("reranker", reranker_node)
|
| 42 |
+
graph.add_node("generator", generator_node)
|
| 43 |
+
graph.add_node("validator", validator_node)
|
| 44 |
+
graph.add_node("retry", _retry_node)
|
| 45 |
+
graph.add_node("hybrid_retrieval", hybrid_retrieval_node)
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
+
graph.set_entry_point("turn_reset")
|
| 48 |
+
graph.add_edge("turn_reset", "classifier")
|
| 49 |
graph.add_conditional_edges(
|
| 50 |
"classifier",
|
| 51 |
route_after_classifier,
|
| 52 |
{
|
| 53 |
"vector_retrieval": "vector_retrieval",
|
| 54 |
+
"graph_retrieval": "graph_retrieval",
|
| 55 |
"hybrid_retrieval": "hybrid_retrieval",
|
| 56 |
},
|
| 57 |
)
|
| 58 |
graph.add_edge("vector_retrieval", "reranker")
|
| 59 |
+
graph.add_edge("graph_retrieval", "reranker")
|
| 60 |
+
graph.add_edge("reranker", "generator")
|
| 61 |
+
graph.add_edge("generator", "validator")
|
| 62 |
graph.add_conditional_edges(
|
| 63 |
"validator",
|
| 64 |
route_after_validator,
|
|
|
|
| 70 |
return graph
|
| 71 |
|
| 72 |
|
| 73 |
+
def get_compiled_graph(checkpointer: BaseCheckpointSaver | None = None):
|
| 74 |
"""Returns the compiled, executable LangGraph. Call once, reuse."""
|
| 75 |
+
return build_graph().compile(checkpointer=checkpointer)
|
src/civicsetu/agent/nodes.py
CHANGED
|
@@ -2,35 +2,70 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import json
|
|
|
|
| 5 |
|
| 6 |
import litellm
|
| 7 |
import structlog
|
| 8 |
|
| 9 |
from civicsetu.agent.state import CivicSetuState
|
| 10 |
from civicsetu.config.settings import get_settings
|
| 11 |
-
from civicsetu.ingestion.embedder import Embedder
|
| 12 |
from civicsetu.models.enums import QueryType, Jurisdiction
|
| 13 |
from civicsetu.models.schemas import Citation, RetrievedChunk
|
| 14 |
from civicsetu.prompts.classifier import CLASSIFIER_PROMPT
|
| 15 |
from civicsetu.prompts.generator import GENERATOR_PROMPT
|
| 16 |
-
from civicsetu.
|
| 17 |
from civicsetu.stores.relational_store import AsyncSessionLocal
|
| 18 |
from civicsetu.stores.vector_store import VectorStore
|
| 19 |
|
| 20 |
log = structlog.get_logger(__name__)
|
| 21 |
settings = get_settings()
|
| 22 |
-
|
| 23 |
|
| 24 |
|
| 25 |
# ── LiteLLM fallback chain ─────────────────────────────────────────────────────
|
| 26 |
|
| 27 |
FALLBACK_MODELS = [
|
| 28 |
settings.primary_model,
|
| 29 |
-
settings.fallback_model_1,
|
| 30 |
settings.fallback_model_2,
|
|
|
|
| 31 |
]
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def _llm_call(prompt: str, system: str, temperature: float = 0.0) -> str:
|
| 35 |
"""
|
| 36 |
LiteLLM call with automatic fallback chain.
|
|
@@ -42,6 +77,7 @@ def _llm_call(prompt: str, system: str, temperature: float = 0.0) -> str:
|
|
| 42 |
]
|
| 43 |
last_error = None
|
| 44 |
for model in FALLBACK_MODELS:
|
|
|
|
| 45 |
try:
|
| 46 |
# Gemini 3.x models degrade below temperature=1.0
|
| 47 |
effective_temp = 1.0 if "gemini-3" in model else temperature
|
|
@@ -51,9 +87,22 @@ def _llm_call(prompt: str, system: str, temperature: float = 0.0) -> str:
|
|
| 51 |
temperature=effective_temp,
|
| 52 |
max_tokens=1024,
|
| 53 |
)
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
except Exception as e:
|
| 56 |
-
|
|
|
|
| 57 |
last_error = e
|
| 58 |
continue
|
| 59 |
|
|
@@ -67,6 +116,16 @@ def classifier_node(state: CivicSetuState) -> dict:
|
|
| 67 |
Classifies query type and rewrites the query for better retrieval.
|
| 68 |
Returns: query_type, rewritten_query
|
| 69 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
query = state["query"]
|
| 71 |
log.info("classifier_node", query=query[:80])
|
| 72 |
|
|
@@ -93,6 +152,7 @@ def classifier_node(state: CivicSetuState) -> dict:
|
|
| 93 |
rewritten = query
|
| 94 |
|
| 95 |
log.info("classified", query_type=query_type.value, rewritten=rewritten[:80])
|
|
|
|
| 96 |
return {"query_type": query_type, "rewritten_query": rewritten}
|
| 97 |
|
| 98 |
|
|
@@ -112,10 +172,13 @@ def vector_retrieval_node(state: CivicSetuState) -> dict:
|
|
| 112 |
query = state.get("rewritten_query") or state["query"]
|
| 113 |
top_k = state.get("top_k", 5)
|
| 114 |
jurisdiction = state.get("jurisdiction_filter")
|
|
|
|
| 115 |
|
| 116 |
log.info("vector_retrieval_node", query=query[:80], top_k=top_k)
|
| 117 |
|
| 118 |
-
|
|
|
|
|
|
|
| 119 |
|
| 120 |
async def _retrieve():
|
| 121 |
async with AsyncSessionLocal() as session:
|
|
@@ -157,7 +220,10 @@ def vector_retrieval_node(state: CivicSetuState) -> dict:
|
|
| 157 |
)
|
| 158 |
return expanded
|
| 159 |
|
|
|
|
| 160 |
chunks = asyncio.run(_retrieve())
|
|
|
|
|
|
|
| 161 |
return {"retrieved_chunks": chunks}
|
| 162 |
|
| 163 |
|
|
@@ -172,6 +238,7 @@ def graph_retrieval_node(state: CivicSetuState) -> dict:
|
|
| 172 |
query = state.get("rewritten_query") or state["query"]
|
| 173 |
jurisdiction = state.get("jurisdiction_filter")
|
| 174 |
top_k = state.get("top_k", 5)
|
|
|
|
| 175 |
|
| 176 |
log.info("graph_retrieval_node", query=query[:80])
|
| 177 |
|
|
@@ -180,16 +247,21 @@ def graph_retrieval_node(state: CivicSetuState) -> dict:
|
|
| 180 |
query=query,
|
| 181 |
jurisdiction=jurisdiction,
|
| 182 |
depth=2,
|
|
|
|
| 183 |
)
|
| 184 |
|
|
|
|
| 185 |
chunks = asyncio.run(_retrieve())
|
| 186 |
log.info("graph_retrieval_complete", results=len(chunks))
|
|
|
|
| 187 |
|
| 188 |
# Fallback: if graph found nothing (no explicit section in query),
|
| 189 |
# run vector retrieval instead
|
| 190 |
if not chunks:
|
| 191 |
log.info("graph_retrieval_fallback_to_vector", query=query[:80])
|
| 192 |
-
|
|
|
|
|
|
|
| 193 |
|
| 194 |
async def _vector_fallback():
|
| 195 |
async with AsyncSessionLocal() as session:
|
|
@@ -201,7 +273,9 @@ def graph_retrieval_node(state: CivicSetuState) -> dict:
|
|
| 201 |
active_only=True,
|
| 202 |
)
|
| 203 |
|
|
|
|
| 204 |
chunks = asyncio.run(_vector_fallback())
|
|
|
|
| 205 |
log.info("graph_fallback_complete", results=len(chunks))
|
| 206 |
|
| 207 |
_MAX_GRAPH_CHUNKS = 25
|
|
@@ -213,6 +287,7 @@ def graph_retrieval_node(state: CivicSetuState) -> dict:
|
|
| 213 |
)
|
| 214 |
chunks = chunks[:_MAX_GRAPH_CHUNKS]
|
| 215 |
|
|
|
|
| 216 |
return {"retrieved_chunks": chunks}
|
| 217 |
|
| 218 |
# ── Node 3: Reranker ───────────────────────────────────────────────────────────
|
|
@@ -223,12 +298,14 @@ def reranker_node(state: CivicSetuState) -> dict:
|
|
| 223 |
Deduplicates by chunk_id first, then scores.
|
| 224 |
Returns: reranked_chunks (top 5 max)
|
| 225 |
"""
|
| 226 |
-
from flashrank import
|
| 227 |
|
| 228 |
chunks = state.get("retrieved_chunks", [])
|
| 229 |
query = state.get("rewritten_query") or state["query"]
|
|
|
|
| 230 |
|
| 231 |
if not chunks:
|
|
|
|
| 232 |
return {"reranked_chunks": []}
|
| 233 |
|
| 234 |
# Deduplicate by chunk_id
|
|
@@ -247,10 +324,14 @@ def reranker_node(state: CivicSetuState) -> dict:
|
|
| 247 |
rankable = [c for c in unique_chunks if not c.is_pinned]
|
| 248 |
|
| 249 |
try:
|
| 250 |
-
|
|
|
|
|
|
|
| 251 |
passages = [{"id": i, "text": c.chunk.text} for i, c in enumerate(rankable)]
|
| 252 |
request = RerankRequest(query=query, passages=passages)
|
|
|
|
| 253 |
results = ranker.rerank(request)
|
|
|
|
| 254 |
|
| 255 |
# Map scores back to chunks
|
| 256 |
id_to_chunk = {i: c for i, c in enumerate(rankable)}
|
|
@@ -270,6 +351,7 @@ def reranker_node(state: CivicSetuState) -> dict:
|
|
| 270 |
reranked = pinned + rankable[:slots_for_ranked]
|
| 271 |
|
| 272 |
log.info("reranker_complete", reranked=len(reranked), pinned=len(pinned))
|
|
|
|
| 273 |
return {"reranked_chunks": reranked}
|
| 274 |
|
| 275 |
|
|
@@ -284,8 +366,11 @@ def generator_node(state: CivicSetuState) -> dict:
|
|
| 284 |
"""
|
| 285 |
query = state["query"]
|
| 286 |
chunks: list[RetrievedChunk] = state.get("reranked_chunks", [])
|
|
|
|
|
|
|
| 287 |
|
| 288 |
if not chunks:
|
|
|
|
| 289 |
return {
|
| 290 |
"raw_response": "Insufficient information found in indexed documents.",
|
| 291 |
"citations": [],
|
|
@@ -305,7 +390,34 @@ def generator_node(state: CivicSetuState) -> dict:
|
|
| 305 |
)
|
| 306 |
context = "\n---\n".join(context_parts)
|
| 307 |
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
system = (
|
| 310 |
"You are CivicSetu, a legal information assistant for Indian law. "
|
| 311 |
"Answer only from the provided context. "
|
|
@@ -313,10 +425,17 @@ def generator_node(state: CivicSetuState) -> dict:
|
|
| 313 |
"Respond with valid JSON only."
|
| 314 |
)
|
| 315 |
|
| 316 |
-
log.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
try:
|
|
|
|
| 319 |
raw = _llm_call(prompt, system, temperature=0.0)
|
|
|
|
| 320 |
raw = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
| 321 |
result = json.loads(raw)
|
| 322 |
|
|
@@ -382,6 +501,7 @@ def generator_node(state: CivicSetuState) -> dict:
|
|
| 382 |
conflict_warnings = []
|
| 383 |
amendment_notice = None
|
| 384 |
|
|
|
|
| 385 |
return {
|
| 386 |
"raw_response": answer,
|
| 387 |
"citations": citations,
|
|
@@ -397,40 +517,16 @@ def generator_node(state: CivicSetuState) -> dict:
|
|
| 397 |
def validator_node(state: CivicSetuState) -> dict:
|
| 398 |
answer = state.get("raw_response", "")
|
| 399 |
chunks = state.get("reranked_chunks", [])
|
|
|
|
| 400 |
|
| 401 |
if not answer or not chunks:
|
|
|
|
| 402 |
return {"hallucination_flag": False, "confidence_score": 0.5}
|
| 403 |
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
context_parts = []
|
| 407 |
-
for i, rc in enumerate(chunks, 1):
|
| 408 |
-
c = rc.chunk
|
| 409 |
-
context_parts.append(
|
| 410 |
-
f"[{i}] {c.doc_name} — {c.section_id}: {c.section_title}\n"
|
| 411 |
-
f"Effective: {c.effective_date}\n"
|
| 412 |
-
f"{c.text}\n"
|
| 413 |
-
)
|
| 414 |
-
context = "\n---\n".join(context_parts)
|
| 415 |
-
|
| 416 |
-
query_type = str(state.get("query_type", "fact_lookup"))
|
| 417 |
-
prompt = VALIDATOR_PROMPT.format(
|
| 418 |
-
query_type=query_type, answer=answer, context=context
|
| 419 |
-
)
|
| 420 |
-
system = "You are a factual grounding scorer. Respond with JSON only."
|
| 421 |
-
|
| 422 |
-
try:
|
| 423 |
-
raw = _llm_call(prompt, system)
|
| 424 |
-
raw = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
| 425 |
-
result = json.loads(raw)
|
| 426 |
-
updated_score = float(result.get("confidence_score", 0.5))
|
| 427 |
-
hallucinated = updated_score < 0.3
|
| 428 |
-
except Exception as e:
|
| 429 |
-
log.warning("validator_failed", error=str(e))
|
| 430 |
-
updated_score = state.get("confidence_score", 0.5)
|
| 431 |
-
hallucinated = False
|
| 432 |
-
|
| 433 |
log.info("validator_node", confidence=updated_score, hallucinated=hallucinated)
|
|
|
|
| 434 |
return {
|
| 435 |
"hallucination_flag": hallucinated,
|
| 436 |
"confidence_score": updated_score,
|
|
@@ -448,10 +544,13 @@ def hybrid_retrieval_node(state: CivicSetuState) -> dict:
|
|
| 448 |
query = state.get("rewritten_query") or state["query"]
|
| 449 |
top_k = state.get("top_k", 5)
|
| 450 |
jurisdiction = state.get("jurisdiction_filter")
|
|
|
|
| 451 |
|
| 452 |
log.info("hybrid_retrieval_node", query=query[:80])
|
| 453 |
|
| 454 |
-
|
|
|
|
|
|
|
| 455 |
|
| 456 |
async def _retrieve():
|
| 457 |
async with AsyncSessionLocal() as session:
|
|
@@ -481,7 +580,9 @@ def hybrid_retrieval_node(state: CivicSetuState) -> dict:
|
|
| 481 |
|
| 482 |
return v_chunks, g_chunks
|
| 483 |
|
|
|
|
| 484 |
v_chunks, g_chunks = asyncio.run(_retrieve())
|
|
|
|
| 485 |
|
| 486 |
log.info(
|
| 487 |
"hybrid_retrieval_complete",
|
|
@@ -489,5 +590,6 @@ def hybrid_retrieval_node(state: CivicSetuState) -> dict:
|
|
| 489 |
graph_chunks=len(g_chunks),
|
| 490 |
total=len(v_chunks) + len(g_chunks),
|
| 491 |
)
|
|
|
|
| 492 |
# Return both — reranker deduplicates by chunk_id
|
| 493 |
return {"retrieved_chunks": v_chunks + g_chunks}
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import json
|
| 5 |
+
import time
|
| 6 |
|
| 7 |
import litellm
|
| 8 |
import structlog
|
| 9 |
|
| 10 |
from civicsetu.agent.state import CivicSetuState
|
| 11 |
from civicsetu.config.settings import get_settings
|
|
|
|
| 12 |
from civicsetu.models.enums import QueryType, Jurisdiction
|
| 13 |
from civicsetu.models.schemas import Citation, RetrievedChunk
|
| 14 |
from civicsetu.prompts.classifier import CLASSIFIER_PROMPT
|
| 15 |
from civicsetu.prompts.generator import GENERATOR_PROMPT
|
| 16 |
+
from civicsetu.retrieval import cached_embed
|
| 17 |
from civicsetu.stores.relational_store import AsyncSessionLocal
|
| 18 |
from civicsetu.stores.vector_store import VectorStore
|
| 19 |
|
| 20 |
log = structlog.get_logger(__name__)
|
| 21 |
settings = get_settings()
|
| 22 |
+
_ranker = None
|
| 23 |
|
| 24 |
|
| 25 |
# ── LiteLLM fallback chain ─────────────────────────────────────────────────────
|
| 26 |
|
| 27 |
FALLBACK_MODELS = [
|
| 28 |
settings.primary_model,
|
|
|
|
| 29 |
settings.fallback_model_2,
|
| 30 |
+
settings.fallback_model_1,
|
| 31 |
]
|
| 32 |
|
| 33 |
|
| 34 |
+
def _get_ranker():
|
| 35 |
+
global _ranker
|
| 36 |
+
if _ranker is not None:
|
| 37 |
+
return _ranker
|
| 38 |
+
|
| 39 |
+
from flashrank import Ranker
|
| 40 |
+
ranker = Ranker(model_name="ms-marco-MiniLM-L-12-v2", cache_dir=".cache/flashrank")
|
| 41 |
+
|
| 42 |
+
# Do not cache unittest mocks, otherwise tests that patch Ranker bleed into each other.
|
| 43 |
+
if type(ranker).__module__ != "unittest.mock":
|
| 44 |
+
_ranker = ranker
|
| 45 |
+
return ranker
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def turn_reset_node(state: CivicSetuState) -> dict:
|
| 49 |
+
"""
|
| 50 |
+
Clear per-turn fields while preserving session-scoped inputs and messages.
|
| 51 |
+
"""
|
| 52 |
+
log.info("turn_reset", session_id=state.get("session_id"))
|
| 53 |
+
return {
|
| 54 |
+
"query_type": None,
|
| 55 |
+
"rewritten_query": None,
|
| 56 |
+
"retrieved_chunks": [],
|
| 57 |
+
"reranked_chunks": [],
|
| 58 |
+
"raw_response": None,
|
| 59 |
+
"citations": [],
|
| 60 |
+
"confidence_score": 0.0,
|
| 61 |
+
"conflict_warnings": [],
|
| 62 |
+
"amendment_notice": None,
|
| 63 |
+
"retry_count": 0,
|
| 64 |
+
"hallucination_flag": False,
|
| 65 |
+
"error": None,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
def _llm_call(prompt: str, system: str, temperature: float = 0.0) -> str:
|
| 70 |
"""
|
| 71 |
LiteLLM call with automatic fallback chain.
|
|
|
|
| 77 |
]
|
| 78 |
last_error = None
|
| 79 |
for model in FALLBACK_MODELS:
|
| 80 |
+
start = time.perf_counter()
|
| 81 |
try:
|
| 82 |
# Gemini 3.x models degrade below temperature=1.0
|
| 83 |
effective_temp = 1.0 if "gemini-3" in model else temperature
|
|
|
|
| 87 |
temperature=effective_temp,
|
| 88 |
max_tokens=1024,
|
| 89 |
)
|
| 90 |
+
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 91 |
+
content = response.choices[0].message.content.strip()
|
| 92 |
+
usage = getattr(response, "usage", None)
|
| 93 |
+
log.info(
|
| 94 |
+
"llm_call_complete",
|
| 95 |
+
model=model,
|
| 96 |
+
duration_ms=duration_ms,
|
| 97 |
+
prompt_chars=len(prompt),
|
| 98 |
+
output_chars=len(content),
|
| 99 |
+
prompt_tokens=getattr(usage, "prompt_tokens", None) if usage else None,
|
| 100 |
+
completion_tokens=getattr(usage, "completion_tokens", None) if usage else None,
|
| 101 |
+
)
|
| 102 |
+
return content
|
| 103 |
except Exception as e:
|
| 104 |
+
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 105 |
+
log.warning("llm_fallback", model=model, duration_ms=duration_ms, error=str(e))
|
| 106 |
last_error = e
|
| 107 |
continue
|
| 108 |
|
|
|
|
| 116 |
Classifies query type and rewrites the query for better retrieval.
|
| 117 |
Returns: query_type, rewritten_query
|
| 118 |
"""
|
| 119 |
+
node_start = time.perf_counter()
|
| 120 |
+
# ── Skip classifier for graph-context queries (section drawer flow) ───────
|
| 121 |
+
if state.get("skip_classifier"):
|
| 122 |
+
log.info("classifier_node_skipped", reason="skip_classifier=True")
|
| 123 |
+
log.info("node_timing", node="classifier", duration_ms=round((time.perf_counter() - node_start) * 1000, 2))
|
| 124 |
+
return {
|
| 125 |
+
"query_type": state["query_type"],
|
| 126 |
+
"rewritten_query": state.get("rewritten_query") or state["query"],
|
| 127 |
+
}
|
| 128 |
+
# ─────────────────────────────────────────────────────────────────────────
|
| 129 |
query = state["query"]
|
| 130 |
log.info("classifier_node", query=query[:80])
|
| 131 |
|
|
|
|
| 152 |
rewritten = query
|
| 153 |
|
| 154 |
log.info("classified", query_type=query_type.value, rewritten=rewritten[:80])
|
| 155 |
+
log.info("node_timing", node="classifier", duration_ms=round((time.perf_counter() - node_start) * 1000, 2))
|
| 156 |
return {"query_type": query_type, "rewritten_query": rewritten}
|
| 157 |
|
| 158 |
|
|
|
|
| 172 |
query = state.get("rewritten_query") or state["query"]
|
| 173 |
top_k = state.get("top_k", 5)
|
| 174 |
jurisdiction = state.get("jurisdiction_filter")
|
| 175 |
+
node_start = time.perf_counter()
|
| 176 |
|
| 177 |
log.info("vector_retrieval_node", query=query[:80], top_k=top_k)
|
| 178 |
|
| 179 |
+
embed_start = time.perf_counter()
|
| 180 |
+
query_embedding = cached_embed(query)
|
| 181 |
+
log.info("stage_timing", node="vector_retrieval", stage="embedding", duration_ms=round((time.perf_counter() - embed_start) * 1000, 2))
|
| 182 |
|
| 183 |
async def _retrieve():
|
| 184 |
async with AsyncSessionLocal() as session:
|
|
|
|
| 220 |
)
|
| 221 |
return expanded
|
| 222 |
|
| 223 |
+
retrieve_start = time.perf_counter()
|
| 224 |
chunks = asyncio.run(_retrieve())
|
| 225 |
+
log.info("stage_timing", node="vector_retrieval", stage="postgres_retrieval", duration_ms=round((time.perf_counter() - retrieve_start) * 1000, 2))
|
| 226 |
+
log.info("node_timing", node="vector_retrieval", duration_ms=round((time.perf_counter() - node_start) * 1000, 2), results=len(chunks))
|
| 227 |
return {"retrieved_chunks": chunks}
|
| 228 |
|
| 229 |
|
|
|
|
| 238 |
query = state.get("rewritten_query") or state["query"]
|
| 239 |
jurisdiction = state.get("jurisdiction_filter")
|
| 240 |
top_k = state.get("top_k", 5)
|
| 241 |
+
node_start = time.perf_counter()
|
| 242 |
|
| 243 |
log.info("graph_retrieval_node", query=query[:80])
|
| 244 |
|
|
|
|
| 247 |
query=query,
|
| 248 |
jurisdiction=jurisdiction,
|
| 249 |
depth=2,
|
| 250 |
+
source_section_id=state.get("source_section_id"),
|
| 251 |
)
|
| 252 |
|
| 253 |
+
retrieve_start = time.perf_counter()
|
| 254 |
chunks = asyncio.run(_retrieve())
|
| 255 |
log.info("graph_retrieval_complete", results=len(chunks))
|
| 256 |
+
log.info("stage_timing", node="graph_retrieval", stage="neo4j_postgres_hydration", duration_ms=round((time.perf_counter() - retrieve_start) * 1000, 2))
|
| 257 |
|
| 258 |
# Fallback: if graph found nothing (no explicit section in query),
|
| 259 |
# run vector retrieval instead
|
| 260 |
if not chunks:
|
| 261 |
log.info("graph_retrieval_fallback_to_vector", query=query[:80])
|
| 262 |
+
embed_start = time.perf_counter()
|
| 263 |
+
query_embedding = cached_embed(query)
|
| 264 |
+
log.info("stage_timing", node="graph_retrieval", stage="fallback_embedding", duration_ms=round((time.perf_counter() - embed_start) * 1000, 2))
|
| 265 |
|
| 266 |
async def _vector_fallback():
|
| 267 |
async with AsyncSessionLocal() as session:
|
|
|
|
| 273 |
active_only=True,
|
| 274 |
)
|
| 275 |
|
| 276 |
+
fallback_start = time.perf_counter()
|
| 277 |
chunks = asyncio.run(_vector_fallback())
|
| 278 |
+
log.info("stage_timing", node="graph_retrieval", stage="fallback_vector_search", duration_ms=round((time.perf_counter() - fallback_start) * 1000, 2))
|
| 279 |
log.info("graph_fallback_complete", results=len(chunks))
|
| 280 |
|
| 281 |
_MAX_GRAPH_CHUNKS = 25
|
|
|
|
| 287 |
)
|
| 288 |
chunks = chunks[:_MAX_GRAPH_CHUNKS]
|
| 289 |
|
| 290 |
+
log.info("node_timing", node="graph_retrieval", duration_ms=round((time.perf_counter() - node_start) * 1000, 2), results=len(chunks))
|
| 291 |
return {"retrieved_chunks": chunks}
|
| 292 |
|
| 293 |
# ── Node 3: Reranker ───────────────────────────────────────────────────────────
|
|
|
|
| 298 |
Deduplicates by chunk_id first, then scores.
|
| 299 |
Returns: reranked_chunks (top 5 max)
|
| 300 |
"""
|
| 301 |
+
from flashrank import RerankRequest
|
| 302 |
|
| 303 |
chunks = state.get("retrieved_chunks", [])
|
| 304 |
query = state.get("rewritten_query") or state["query"]
|
| 305 |
+
node_start = time.perf_counter()
|
| 306 |
|
| 307 |
if not chunks:
|
| 308 |
+
log.info("node_timing", node="reranker", duration_ms=round((time.perf_counter() - node_start) * 1000, 2), reranked=0)
|
| 309 |
return {"reranked_chunks": []}
|
| 310 |
|
| 311 |
# Deduplicate by chunk_id
|
|
|
|
| 324 |
rankable = [c for c in unique_chunks if not c.is_pinned]
|
| 325 |
|
| 326 |
try:
|
| 327 |
+
init_start = time.perf_counter()
|
| 328 |
+
ranker = _get_ranker()
|
| 329 |
+
log.info("stage_timing", node="reranker", stage="ranker_init", duration_ms=round((time.perf_counter() - init_start) * 1000, 2))
|
| 330 |
passages = [{"id": i, "text": c.chunk.text} for i, c in enumerate(rankable)]
|
| 331 |
request = RerankRequest(query=query, passages=passages)
|
| 332 |
+
inference_start = time.perf_counter()
|
| 333 |
results = ranker.rerank(request)
|
| 334 |
+
log.info("stage_timing", node="reranker", stage="ranker_inference", duration_ms=round((time.perf_counter() - inference_start) * 1000, 2), passages=len(passages))
|
| 335 |
|
| 336 |
# Map scores back to chunks
|
| 337 |
id_to_chunk = {i: c for i, c in enumerate(rankable)}
|
|
|
|
| 351 |
reranked = pinned + rankable[:slots_for_ranked]
|
| 352 |
|
| 353 |
log.info("reranker_complete", reranked=len(reranked), pinned=len(pinned))
|
| 354 |
+
log.info("node_timing", node="reranker", duration_ms=round((time.perf_counter() - node_start) * 1000, 2), unique_chunks=len(unique_chunks), rankable=len(rankable), pinned=len(pinned), reranked=len(reranked))
|
| 355 |
return {"reranked_chunks": reranked}
|
| 356 |
|
| 357 |
|
|
|
|
| 366 |
"""
|
| 367 |
query = state["query"]
|
| 368 |
chunks: list[RetrievedChunk] = state.get("reranked_chunks", [])
|
| 369 |
+
messages = state.get("messages", [])
|
| 370 |
+
node_start = time.perf_counter()
|
| 371 |
|
| 372 |
if not chunks:
|
| 373 |
+
log.info("node_timing", node="generator", duration_ms=round((time.perf_counter() - node_start) * 1000, 2), context_chunks=0)
|
| 374 |
return {
|
| 375 |
"raw_response": "Insufficient information found in indexed documents.",
|
| 376 |
"citations": [],
|
|
|
|
| 390 |
)
|
| 391 |
context = "\n---\n".join(context_parts)
|
| 392 |
|
| 393 |
+
recent_messages = messages[-6:]
|
| 394 |
+
if recent_messages:
|
| 395 |
+
history_lines = []
|
| 396 |
+
for message in recent_messages:
|
| 397 |
+
role = getattr(message, "role", None)
|
| 398 |
+
content = getattr(message, "content", None)
|
| 399 |
+
if role is None and isinstance(message, dict):
|
| 400 |
+
role = message.get("role")
|
| 401 |
+
content = message.get("content")
|
| 402 |
+
if not role or not content:
|
| 403 |
+
continue
|
| 404 |
+
role_label = "User" if role == "user" else "Assistant"
|
| 405 |
+
history_lines.append(f"{role_label}: {content}")
|
| 406 |
+
conversation_history_block = (
|
| 407 |
+
"Prior conversation (for context only; answer the current question):\n"
|
| 408 |
+
+ "\n".join(history_lines)
|
| 409 |
+
+ "\n\n"
|
| 410 |
+
if history_lines
|
| 411 |
+
else ""
|
| 412 |
+
)
|
| 413 |
+
else:
|
| 414 |
+
conversation_history_block = ""
|
| 415 |
+
|
| 416 |
+
prompt = GENERATOR_PROMPT.format(
|
| 417 |
+
query=query,
|
| 418 |
+
context=context,
|
| 419 |
+
conversation_history_block=conversation_history_block,
|
| 420 |
+
)
|
| 421 |
system = (
|
| 422 |
"You are CivicSetu, a legal information assistant for Indian law. "
|
| 423 |
"Answer only from the provided context. "
|
|
|
|
| 425 |
"Respond with valid JSON only."
|
| 426 |
)
|
| 427 |
|
| 428 |
+
log.info(
|
| 429 |
+
"generator_node",
|
| 430 |
+
query=query[:80],
|
| 431 |
+
context_chunks=len(chunks),
|
| 432 |
+
history_messages=len(recent_messages),
|
| 433 |
+
)
|
| 434 |
|
| 435 |
try:
|
| 436 |
+
llm_start = time.perf_counter()
|
| 437 |
raw = _llm_call(prompt, system, temperature=0.0)
|
| 438 |
+
log.info("stage_timing", node="generator", stage="llm", duration_ms=round((time.perf_counter() - llm_start) * 1000, 2))
|
| 439 |
raw = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
| 440 |
result = json.loads(raw)
|
| 441 |
|
|
|
|
| 501 |
conflict_warnings = []
|
| 502 |
amendment_notice = None
|
| 503 |
|
| 504 |
+
log.info("node_timing", node="generator", duration_ms=round((time.perf_counter() - node_start) * 1000, 2), context_chunks=len(chunks))
|
| 505 |
return {
|
| 506 |
"raw_response": answer,
|
| 507 |
"citations": citations,
|
|
|
|
| 517 |
def validator_node(state: CivicSetuState) -> dict:
|
| 518 |
answer = state.get("raw_response", "")
|
| 519 |
chunks = state.get("reranked_chunks", [])
|
| 520 |
+
node_start = time.perf_counter()
|
| 521 |
|
| 522 |
if not answer or not chunks:
|
| 523 |
+
log.info("node_timing", node="validator", duration_ms=round((time.perf_counter() - node_start) * 1000, 2), skipped=True)
|
| 524 |
return {"hallucination_flag": False, "confidence_score": 0.5}
|
| 525 |
|
| 526 |
+
updated_score = state.get("confidence_score", 0.5)
|
| 527 |
+
hallucinated = updated_score < 0.2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
log.info("validator_node", confidence=updated_score, hallucinated=hallucinated)
|
| 529 |
+
log.info("node_timing", node="validator", duration_ms=round((time.perf_counter() - node_start) * 1000, 2))
|
| 530 |
return {
|
| 531 |
"hallucination_flag": hallucinated,
|
| 532 |
"confidence_score": updated_score,
|
|
|
|
| 544 |
query = state.get("rewritten_query") or state["query"]
|
| 545 |
top_k = state.get("top_k", 5)
|
| 546 |
jurisdiction = state.get("jurisdiction_filter")
|
| 547 |
+
node_start = time.perf_counter()
|
| 548 |
|
| 549 |
log.info("hybrid_retrieval_node", query=query[:80])
|
| 550 |
|
| 551 |
+
embed_start = time.perf_counter()
|
| 552 |
+
query_embedding = cached_embed(query)
|
| 553 |
+
log.info("stage_timing", node="hybrid_retrieval", stage="embedding", duration_ms=round((time.perf_counter() - embed_start) * 1000, 2))
|
| 554 |
|
| 555 |
async def _retrieve():
|
| 556 |
async with AsyncSessionLocal() as session:
|
|
|
|
| 580 |
|
| 581 |
return v_chunks, g_chunks
|
| 582 |
|
| 583 |
+
retrieve_start = time.perf_counter()
|
| 584 |
v_chunks, g_chunks = asyncio.run(_retrieve())
|
| 585 |
+
log.info("stage_timing", node="hybrid_retrieval", stage="vector_graph_parallel", duration_ms=round((time.perf_counter() - retrieve_start) * 1000, 2))
|
| 586 |
|
| 587 |
log.info(
|
| 588 |
"hybrid_retrieval_complete",
|
|
|
|
| 590 |
graph_chunks=len(g_chunks),
|
| 591 |
total=len(v_chunks) + len(g_chunks),
|
| 592 |
)
|
| 593 |
+
log.info("node_timing", node="hybrid_retrieval", duration_ms=round((time.perf_counter() - node_start) * 1000, 2), total=len(v_chunks) + len(g_chunks))
|
| 594 |
# Return both — reranker deduplicates by chunk_id
|
| 595 |
return {"retrieved_chunks": v_chunks + g_chunks}
|
src/civicsetu/agent/state.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import operator
|
| 4 |
-
from typing import Annotated
|
| 5 |
|
| 6 |
from typing_extensions import TypedDict
|
| 7 |
|
| 8 |
from civicsetu.models.enums import Jurisdiction, QueryType
|
| 9 |
-
from civicsetu.models.schemas import Citation, RetrievedChunk
|
| 10 |
|
| 11 |
|
| 12 |
class CivicSetuState(TypedDict):
|
|
@@ -15,13 +15,14 @@ class CivicSetuState(TypedDict):
|
|
| 15 |
session_id: str | None
|
| 16 |
jurisdiction_filter: Jurisdiction | None
|
| 17 |
top_k: int
|
|
|
|
| 18 |
|
| 19 |
# ── Classification ─────────────────────────────────────────────────────────
|
| 20 |
query_type: QueryType | None
|
| 21 |
rewritten_query: str | None # expanded/clarified query
|
| 22 |
|
| 23 |
# ── Retrieval ──────────────────────────────────────────────────────────────
|
| 24 |
-
retrieved_chunks:
|
| 25 |
reranked_chunks: list[RetrievedChunk]
|
| 26 |
|
| 27 |
# ── Generation ─────────────────────────────────────────────────────────────
|
|
@@ -35,3 +36,5 @@ class CivicSetuState(TypedDict):
|
|
| 35 |
retry_count: int
|
| 36 |
hallucination_flag: bool
|
| 37 |
error: str | None
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import operator
|
| 4 |
+
from typing import Annotated, NotRequired
|
| 5 |
|
| 6 |
from typing_extensions import TypedDict
|
| 7 |
|
| 8 |
from civicsetu.models.enums import Jurisdiction, QueryType
|
| 9 |
+
from civicsetu.models.schemas import ChatMessage, Citation, RetrievedChunk
|
| 10 |
|
| 11 |
|
| 12 |
class CivicSetuState(TypedDict):
|
|
|
|
| 15 |
session_id: str | None
|
| 16 |
jurisdiction_filter: Jurisdiction | None
|
| 17 |
top_k: int
|
| 18 |
+
messages: Annotated[list[ChatMessage], operator.add]
|
| 19 |
|
| 20 |
# ── Classification ─────────────────────────────────────────────────────────
|
| 21 |
query_type: QueryType | None
|
| 22 |
rewritten_query: str | None # expanded/clarified query
|
| 23 |
|
| 24 |
# ── Retrieval ──────────────────────────────────────────────────────────────
|
| 25 |
+
retrieved_chunks: list[RetrievedChunk]
|
| 26 |
reranked_chunks: list[RetrievedChunk]
|
| 27 |
|
| 28 |
# ── Generation ─────────────────────────────────────────────────────────────
|
|
|
|
| 36 |
retry_count: int
|
| 37 |
hallucination_flag: bool
|
| 38 |
error: str | None
|
| 39 |
+
skip_classifier: NotRequired[bool]
|
| 40 |
+
source_section_id: NotRequired[str]
|
src/civicsetu/api/main.py
CHANGED
|
@@ -1,455 +1,39 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
|
| 5 |
import structlog
|
| 6 |
from fastapi import FastAPI
|
| 7 |
-
from fastapi.responses import HTMLResponse
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
|
| 10 |
from civicsetu.api.middleware.logging import LoggingMiddleware
|
| 11 |
-
from civicsetu.api.routes import health, query
|
| 12 |
from civicsetu.config.settings import get_settings
|
| 13 |
-
from civicsetu.stores.graph_store import
|
| 14 |
|
| 15 |
log = structlog.get_logger(__name__)
|
| 16 |
settings = get_settings()
|
| 17 |
|
| 18 |
-
def get_landing_page_html() -> str:
|
| 19 |
-
return """
|
| 20 |
-
<!DOCTYPE html>
|
| 21 |
-
<html lang="en">
|
| 22 |
-
<head>
|
| 23 |
-
<title>CivicSetu — AI-Powered RERA Research</title>
|
| 24 |
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 25 |
-
<meta name="description" content="Open-source RAG system for querying Indian civic and legal documents with accurate citations and cross-reference traversal.">
|
| 26 |
-
<script>
|
| 27 |
-
// Tailwind dark mode: class-based
|
| 28 |
-
tailwind.config = {
|
| 29 |
-
darkMode: 'class'
|
| 30 |
-
}
|
| 31 |
-
</script>
|
| 32 |
-
<script src="https://cdn.tailwindcss.com"></script>
|
| 33 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 34 |
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 35 |
-
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Merriweather:wght@400;700&display=swap" rel="stylesheet">
|
| 36 |
-
<style>
|
| 37 |
-
body { font-family: 'Inter', sans-serif; }
|
| 38 |
-
.answer-body { font-family: 'Merriweather', serif; }
|
| 39 |
-
.gradient-text {
|
| 40 |
-
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #d946ef 100%);
|
| 41 |
-
-webkit-background-clip: text;
|
| 42 |
-
-webkit-text-fill-color: transparent;
|
| 43 |
-
background-clip: text;
|
| 44 |
-
}
|
| 45 |
-
.glass {
|
| 46 |
-
background: rgba(255, 255, 255, 0.85);
|
| 47 |
-
backdrop-filter: blur(12px);
|
| 48 |
-
-webkit-backdrop-filter: blur(12px);
|
| 49 |
-
}
|
| 50 |
-
.dark .glass {
|
| 51 |
-
background: rgba(30, 41, 59, 0.85);
|
| 52 |
-
}
|
| 53 |
-
.pulse-ring {
|
| 54 |
-
animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 55 |
-
}
|
| 56 |
-
@keyframes pulse-ring {
|
| 57 |
-
0%, 100% { opacity: 1; }
|
| 58 |
-
50% { opacity: 0.5; }
|
| 59 |
-
}
|
| 60 |
-
.slide-up {
|
| 61 |
-
animation: slideUp 0.4s ease-out forwards;
|
| 62 |
-
}
|
| 63 |
-
@keyframes slideUp {
|
| 64 |
-
from { opacity: 0; transform: translateY(20px); }
|
| 65 |
-
to { opacity: 1; transform: translateY(0); }
|
| 66 |
-
}
|
| 67 |
-
.citation-card {
|
| 68 |
-
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
| 69 |
-
}
|
| 70 |
-
.citation-card:hover {
|
| 71 |
-
transform: translateY(-2px);
|
| 72 |
-
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
| 73 |
-
}
|
| 74 |
-
.example-chip {
|
| 75 |
-
transition: all 0.2s ease;
|
| 76 |
-
}
|
| 77 |
-
.example-chip:hover {
|
| 78 |
-
transform: scale(1.02);
|
| 79 |
-
}
|
| 80 |
-
/* Theme toggle button */
|
| 81 |
-
.theme-toggle {
|
| 82 |
-
position: relative;
|
| 83 |
-
width: 40px;
|
| 84 |
-
height: 40px;
|
| 85 |
-
border-radius: 12px;
|
| 86 |
-
display: flex;
|
| 87 |
-
align-items: center;
|
| 88 |
-
justify-center;
|
| 89 |
-
cursor: pointer;
|
| 90 |
-
transition: all 0.2s ease;
|
| 91 |
-
background: transparent;
|
| 92 |
-
border: none;
|
| 93 |
-
}
|
| 94 |
-
.theme-toggle:hover {
|
| 95 |
-
background: rgba(0, 0, 0, 0.05);
|
| 96 |
-
}
|
| 97 |
-
.dark .theme-toggle:hover {
|
| 98 |
-
background: rgba(255, 255, 255, 0.1);
|
| 99 |
-
}
|
| 100 |
-
.theme-toggle .sun { display: block; }
|
| 101 |
-
.theme-toggle .moon { display: none; }
|
| 102 |
-
.dark .theme-toggle .sun { display: none; }
|
| 103 |
-
.dark .theme-toggle .moon { display: block; }
|
| 104 |
-
</style>
|
| 105 |
-
</head>
|
| 106 |
-
<body class="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-indigo-950 transition-colors duration-300">
|
| 107 |
-
<header class="glass sticky top-0 z-50 border-b border-white/20 dark:border-slate-700/20">
|
| 108 |
-
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
| 109 |
-
<div class="flex items-center gap-3">
|
| 110 |
-
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center shadow-lg">
|
| 111 |
-
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 112 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
| 113 |
-
</svg>
|
| 114 |
-
</div>
|
| 115 |
-
<div>
|
| 116 |
-
<h1 class="text-xl font-bold gradient-text">CivicSetu</h1>
|
| 117 |
-
<p class="text-xs text-gray-500 dark:text-gray-400">RERA Research Engine</p>
|
| 118 |
-
</div>
|
| 119 |
-
</div>
|
| 120 |
-
<div class="flex items-center gap-3">
|
| 121 |
-
<span class="hidden sm:inline-flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-3 py-1 rounded-full">
|
| 122 |
-
<span class="w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full pulse-ring"></span>
|
| 123 |
-
Live
|
| 124 |
-
</span>
|
| 125 |
-
<button id="theme-toggle" class="theme-toggle text-gray-600 dark:text-gray-300" aria-label="Toggle dark mode">
|
| 126 |
-
<!-- Sun (shown in dark mode to switch to light) -->
|
| 127 |
-
<svg class="sun w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 128 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
|
| 129 |
-
</svg>
|
| 130 |
-
<!-- Moon (shown in light mode to switch to dark) -->
|
| 131 |
-
<svg class="moon w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 132 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
| 133 |
-
</svg>
|
| 134 |
-
</button>
|
| 135 |
-
<a href="https://github.com/adeshboudh/civicsetu" target="_blank" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">
|
| 136 |
-
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
| 137 |
-
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
|
| 138 |
-
</svg>
|
| 139 |
-
</a>
|
| 140 |
-
</div>
|
| 141 |
-
</div>
|
| 142 |
-
</header>
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
<input id="query" type="text"
|
| 162 |
-
placeholder="Ask about RERA: promoter obligations, agent registration, penalties..."
|
| 163 |
-
class="w-full pl-12 pr-4 py-4 text-lg border-2 border-gray-200 dark:border-gray-600 bg-white dark:bg-slate-800 text-gray-900 dark:text-white rounded-2xl focus:border-blue-400 dark:focus:border-blue-400 focus:outline-none focus:ring-4 focus:ring-blue-100 dark:focus:ring-blue-900 transition-all">
|
| 164 |
-
</div>
|
| 165 |
-
<div class="flex gap-3">
|
| 166 |
-
<select id="jurisdiction" class="px-4 py-4 text-gray-700 dark:text-gray-200 bg-white dark:bg-slate-800 border-2 border-gray-200 dark:border-gray-600 rounded-2xl focus:border-blue-400 dark:focus:border-blue-400 focus:outline-none cursor-pointer">
|
| 167 |
-
<option value="">All India</option>
|
| 168 |
-
<option value="CENTRAL">RERA Act (Central)</option>
|
| 169 |
-
<option value="MAHARASHTRA">Maharashtra</option>
|
| 170 |
-
<option value="UTTAR_PRADESH">Uttar Pradesh</option>
|
| 171 |
-
<option value="KARNATAKA">Karnataka</option>
|
| 172 |
-
<option value="TAMIL_NADU">Tamil Nadu</option>
|
| 173 |
-
</select>
|
| 174 |
-
<button onclick="doQuery()"
|
| 175 |
-
class="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-8 py-4 rounded-2xl font-semibold text-lg shadow-lg hover:shadow-xl transition-all duration-200 flex items-center gap-2">
|
| 176 |
-
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 177 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
| 178 |
-
</svg>
|
| 179 |
-
<span>Ask</span>
|
| 180 |
-
</button>
|
| 181 |
-
</div>
|
| 182 |
-
</div>
|
| 183 |
-
|
| 184 |
-
<div class="flex flex-wrap gap-2">
|
| 185 |
-
<span class="text-sm text-gray-500 dark:text-gray-400 mr-2">Try:</span>
|
| 186 |
-
<button onclick="setQuery(this.textContent)" class="example-chip text-sm bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-4 py-2 rounded-full border border-blue-200 dark:border-blue-700">
|
| 187 |
-
What are promoter obligations under RERA?
|
| 188 |
-
</button>
|
| 189 |
-
<button onclick="setQuery(this.textContent)" class="example-chip text-sm bg-purple-50 dark:bg-purple-900/30 hover:bg-purple-100 dark:hover:bg-purple-900/50 text-purple-700 dark:text-purple-300 px-4 py-2 rounded-full border border-purple-200 dark:border-purple-700">
|
| 190 |
-
Penalties for delayed possession
|
| 191 |
-
</button>
|
| 192 |
-
<button onclick="setQuery(this.textContent)" class="example-chip text-sm bg-green-50 dark:bg-green-900/30 hover:bg-green-100 dark:hover:bg-green-900/50 text-green-700 dark:text-green-300 px-4 py-2 rounded-full border border-green-200 dark:border-green-700">
|
| 193 |
-
Agent registration requirements
|
| 194 |
-
</button>
|
| 195 |
-
<button onclick="setQuery(this.textContent)" class="example-chip text-sm bg-amber-50 dark:bg-amber-900/30 hover:bg-amber-100 dark:hover:bg-amber-900/50 text-amber-700 dark:text-amber-300 px-4 py-2 rounded-full border border-amber-200 dark:border-amber-700">
|
| 196 |
-
Complaint filing process
|
| 197 |
-
</button>
|
| 198 |
-
</div>
|
| 199 |
-
</div>
|
| 200 |
-
|
| 201 |
-
<div id="response" class="hidden space-y-6">
|
| 202 |
-
<div id="loading" class="glass rounded-2xl p-12 text-center">
|
| 203 |
-
<div class="inline-flex items-center gap-3">
|
| 204 |
-
<div class="w-10 h-10 border-4 border-blue-200 dark:border-slate-600 border-t-blue-600 dark:border-t-blue-400 rounded-full animate-spin"></div>
|
| 205 |
-
<span class="text-gray-600 dark:text-gray-300 text-lg">Analyzing RERA regulations...</span>
|
| 206 |
-
</div>
|
| 207 |
-
<p class="text-gray-400 dark:text-gray-500 text-sm mt-3">This may take 10-15 seconds</p>
|
| 208 |
-
</div>
|
| 209 |
-
|
| 210 |
-
<div id="answer-card" class="glass rounded-2xl shadow-xl p-8 slide-up hidden">
|
| 211 |
-
<div class="flex items-start gap-4 mb-6">
|
| 212 |
-
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center flex-shrink-0">
|
| 213 |
-
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 214 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 215 |
-
</svg>
|
| 216 |
-
</div>
|
| 217 |
-
<div class="flex-1">
|
| 218 |
-
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-1">Answer</h3>
|
| 219 |
-
<div id="query-type-badge" class="inline-block text-xs bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded-full"></div>
|
| 220 |
-
</div>
|
| 221 |
-
</div>
|
| 222 |
-
<div id="answer" class="answer-body text-gray-800 dark:text-gray-200 text-lg leading-relaxed"></div>
|
| 223 |
-
<div id="warnings" class="mt-6 hidden"></div>
|
| 224 |
-
</div>
|
| 225 |
-
|
| 226 |
-
<div id="stats-row" class="grid grid-cols-2 md:grid-cols-4 gap-4 hidden">
|
| 227 |
-
<div class="glass rounded-xl p-4 text-center">
|
| 228 |
-
<div id="confidence-value" class="text-2xl font-bold text-blue-600 dark:text-blue-400">--</div>
|
| 229 |
-
<div class="text-xs text-gray-500 dark:text-gray-400">Confidence</div>
|
| 230 |
-
</div>
|
| 231 |
-
<div class="glass rounded-xl p-4 text-center">
|
| 232 |
-
<div id="citations-count" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</div>
|
| 233 |
-
<div class="text-xs text-gray-500 dark:text-gray-400">Citations</div>
|
| 234 |
-
</div>
|
| 235 |
-
<div class="glass rounded-xl p-4 text-center">
|
| 236 |
-
<div id="chunks-count" class="text-2xl font-bold text-green-600 dark:text-green-400">--</div>
|
| 237 |
-
<div class="text-xs text-gray-500 dark:text-gray-400">Chunks</div>
|
| 238 |
-
</div>
|
| 239 |
-
<div class="glass rounded-xl p-4 text-center">
|
| 240 |
-
<div id="retry-count" class="text-2xl font-bold text-amber-600 dark:text-amber-400">--</div>
|
| 241 |
-
<div class="text-xs text-gray-500 dark:text-gray-400">Retries</div>
|
| 242 |
-
</div>
|
| 243 |
-
</div>
|
| 244 |
-
|
| 245 |
-
<div id="citations-card" class="glass rounded-2xl shadow-xl p-8 hidden">
|
| 246 |
-
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4 flex items-center gap-2">
|
| 247 |
-
<svg class="w-5 h-5 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 248 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
| 249 |
-
</svg>
|
| 250 |
-
Legal Citations
|
| 251 |
-
</h3>
|
| 252 |
-
<div id="citations" class="grid gap-3"></div>
|
| 253 |
-
</div>
|
| 254 |
-
|
| 255 |
-
<div class="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 rounded-xl p-4 text-sm text-amber-800 dark:text-amber-200">
|
| 256 |
-
<div class="flex items-start gap-2">
|
| 257 |
-
<svg class="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 258 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
| 259 |
-
</svg>
|
| 260 |
-
<div>
|
| 261 |
-
<strong>Legal Disclaimer:</strong> This is AI-generated legal information, not legal advice.
|
| 262 |
-
Always verify with a qualified lawyer or the official gazette before making any legal decisions.
|
| 263 |
-
</div>
|
| 264 |
-
</div>
|
| 265 |
-
</div>
|
| 266 |
-
</div>
|
| 267 |
-
|
| 268 |
-
<div class="mt-16 grid md:grid-cols-3 gap-6">
|
| 269 |
-
<div class="glass rounded-2xl p-6 border border-white/30 dark:border-slate-700/30 hover:shadow-lg transition-shadow">
|
| 270 |
-
<div class="w-12 h-12 rounded-xl bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center mb-4">
|
| 271 |
-
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 272 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
| 273 |
-
</svg>
|
| 274 |
-
</div>
|
| 275 |
-
<h3 class="font-semibold text-gray-800 dark:text-white mb-2">5-Jurisdiction Coverage</h3>
|
| 276 |
-
<p class="text-sm text-gray-600 dark:text-gray-400">Central RERA Act + Maharashtra, UP, Karnataka, and Tamil Nadu state rules</p>
|
| 277 |
-
</div>
|
| 278 |
-
<div class="glass rounded-2xl p-6 border border-white/30 dark:border-slate-700/30 hover:shadow-lg transition-shadow">
|
| 279 |
-
<div class="w-12 h-12 rounded-xl bg-purple-100 dark:bg-purple-900/50 flex items-center justify-center mb-4">
|
| 280 |
-
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 281 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
| 282 |
-
</svg>
|
| 283 |
-
</div>
|
| 284 |
-
<h3 class="font-semibold text-gray-800 dark:text-white mb-2">Cross-Reference Graph</h3>
|
| 285 |
-
<p class="text-sm text-gray-600 dark:text-gray-400">Neo4j-powered traversal between sections and DERIVED_FROM relationships</p>
|
| 286 |
-
</div>
|
| 287 |
-
<div class="glass rounded-2xl p-6 border border-white/30 dark:border-slate-700/30 hover:shadow-lg transition-shadow">
|
| 288 |
-
<div class="w-12 h-12 rounded-xl bg-green-100 dark:bg-green-900/50 flex items-center justify-center mb-4">
|
| 289 |
-
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 290 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 291 |
-
</svg>
|
| 292 |
-
</div>
|
| 293 |
-
<h3 class="font-semibold text-gray-800 dark:text-white mb-2">Hallucination Detection</h3>
|
| 294 |
-
<p class="text-sm text-gray-600 dark:text-gray-400">Validator agent with confidence scoring and citation verification</p>
|
| 295 |
-
</div>
|
| 296 |
-
</div>
|
| 297 |
-
|
| 298 |
-
<div class="mt-12 glass rounded-2xl p-8 border border-white/30 dark:border-slate-700/30">
|
| 299 |
-
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">Document Coverage</h3>
|
| 300 |
-
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 301 |
-
<div class="flex items-center gap-3 p-4 bg-white/50 dark:bg-slate-800/50 rounded-xl">
|
| 302 |
-
<div class="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold text-sm">RERA</div>
|
| 303 |
-
<div>
|
| 304 |
-
<div class="font-medium text-gray-800 dark:text-white">RERA Act 2016</div>
|
| 305 |
-
<div class="text-xs text-gray-500 dark:text-gray-400">Central • 224 sections</div>
|
| 306 |
-
</div>
|
| 307 |
-
</div>
|
| 308 |
-
<div class="flex items-center gap-3 p-4 bg-white/50 dark:bg-slate-800/50 rounded-xl">
|
| 309 |
-
<div class="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/50 flex items-center justify-center text-purple-600 dark:text-purple-400 font-bold text-sm">MH</div>
|
| 310 |
-
<div>
|
| 311 |
-
<div class="font-medium text-gray-800 dark:text-white">Maharashtra Rules 2017</div>
|
| 312 |
-
<div class="text-xs text-gray-500 dark:text-gray-400">Maharashtra • 214 sections</div>
|
| 313 |
-
</div>
|
| 314 |
-
</div>
|
| 315 |
-
<div class="flex items-center gap-3 p-4 bg-white/50 dark:bg-slate-800/50 rounded-xl">
|
| 316 |
-
<div class="w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/50 flex items-center justify-center text-green-600 dark:text-green-400 font-bold text-sm">UP</div>
|
| 317 |
-
<div>
|
| 318 |
-
<div class="font-medium text-gray-800 dark:text-white">UP RERA Rules 2016</div>
|
| 319 |
-
<div class="text-xs text-gray-500 dark:text-gray-400">Uttar Pradesh</div>
|
| 320 |
-
</div>
|
| 321 |
-
</div>
|
| 322 |
-
<div class="flex items-center gap-3 p-4 bg-white/50 dark:bg-slate-800/50 rounded-xl">
|
| 323 |
-
<div class="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center text-amber-600 dark:text-amber-400 font-bold text-sm">KA</div>
|
| 324 |
-
<div>
|
| 325 |
-
<div class="font-medium text-gray-800 dark:text-white">Karnataka Rules 2017</div>
|
| 326 |
-
<div class="text-xs text-gray-500 dark:text-gray-400">Karnataka</div>
|
| 327 |
-
</div>
|
| 328 |
-
</div>
|
| 329 |
-
<div class="flex items-center gap-3 p-4 bg-white/50 dark:bg-slate-800/50 rounded-xl">
|
| 330 |
-
<div class="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/50 flex items-center justify-center text-red-600 dark:text-red-400 font-bold text-sm">TN</div>
|
| 331 |
-
<div>
|
| 332 |
-
<div class="font-medium text-gray-800 dark:text-white">Tamil Nadu Rules 2017</div>
|
| 333 |
-
<div class="text-xs text-gray-500 dark:text-gray-400">Tamil Nadu</div>
|
| 334 |
-
</div>
|
| 335 |
-
</div>
|
| 336 |
-
</div>
|
| 337 |
-
</div>
|
| 338 |
-
</main>
|
| 339 |
-
|
| 340 |
-
<footer class="mt-16 border-t border-gray-200 dark:border-slate-700 bg-white/50 dark:bg-slate-900/50">
|
| 341 |
-
<div class="max-w-6xl mx-auto px-6 py-8">
|
| 342 |
-
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
| 343 |
-
<div class="text-sm text-gray-500 dark:text-gray-400">
|
| 344 |
-
<span class="font-semibold text-gray-700 dark:text-gray-200">CivicSetu</span> — Open-source RAG for Indian civic documents
|
| 345 |
-
</div>
|
| 346 |
-
<div class="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
|
| 347 |
-
<a href="https://github.com/adeshboudh/civicsetu" class="hover:text-gray-700 dark:hover:text-gray-200 transition-colors">GitHub</a>
|
| 348 |
-
<a href="/docs" class="hover:text-gray-700 dark:hover:text-gray-200 transition-colors">API Docs</a>
|
| 349 |
-
</div>
|
| 350 |
-
</div>
|
| 351 |
-
</div>
|
| 352 |
-
</footer>
|
| 353 |
-
|
| 354 |
-
<script>
|
| 355 |
-
// Dark mode toggle
|
| 356 |
-
const toggleBtn = document.getElementById('theme-toggle');
|
| 357 |
-
const html = document.documentElement;
|
| 358 |
-
|
| 359 |
-
// Init: check localStorage or system preference
|
| 360 |
-
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
| 361 |
-
html.classList.add('dark');
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
toggleBtn.addEventListener('click', () => {
|
| 365 |
-
html.classList.toggle('dark');
|
| 366 |
-
localStorage.setItem('theme', html.classList.contains('dark') ? 'dark' : 'light');
|
| 367 |
-
});
|
| 368 |
-
|
| 369 |
-
function setQuery(text) {
|
| 370 |
-
document.getElementById('query').value = text;
|
| 371 |
-
document.getElementById('query').focus();
|
| 372 |
-
}
|
| 373 |
-
|
| 374 |
-
async function doQuery() {
|
| 375 |
-
const queryText = document.getElementById('query').value.trim();
|
| 376 |
-
if (!queryText) return;
|
| 377 |
-
|
| 378 |
-
const jurisdiction = document.getElementById('jurisdiction').value;
|
| 379 |
-
const responseDiv = document.getElementById('response');
|
| 380 |
-
|
| 381 |
-
responseDiv.classList.remove('hidden');
|
| 382 |
-
document.getElementById('loading').classList.remove('hidden');
|
| 383 |
-
document.getElementById('answer-card').classList.add('hidden');
|
| 384 |
-
document.getElementById('stats-row').classList.add('hidden');
|
| 385 |
-
document.getElementById('citations-card').classList.add('hidden');
|
| 386 |
-
document.getElementById('warnings').classList.add('hidden');
|
| 387 |
-
|
| 388 |
-
const payload = { query: queryText, top_k: 5 };
|
| 389 |
-
if (jurisdiction) payload.jurisdiction_filter = jurisdiction;
|
| 390 |
-
|
| 391 |
-
try {
|
| 392 |
-
const res = await fetch('/api/v1/query', {
|
| 393 |
-
method: 'POST',
|
| 394 |
-
headers: { 'Content-Type': 'application/json' },
|
| 395 |
-
body: JSON.stringify(payload)
|
| 396 |
-
});
|
| 397 |
-
const data = await res.json();
|
| 398 |
-
|
| 399 |
-
document.getElementById('loading').classList.add('hidden');
|
| 400 |
-
document.getElementById('answer-card').classList.remove('hidden');
|
| 401 |
-
document.getElementById('answer').innerHTML = (data.answer || '').replace(/\\n/g, '<br>');
|
| 402 |
-
document.getElementById('query-type-badge').textContent = data.query_type_resolved || 'general';
|
| 403 |
-
|
| 404 |
-
const warningsDiv = document.getElementById('warnings');
|
| 405 |
-
let warningsHtml = '';
|
| 406 |
-
if (data.conflict_warnings && data.conflict_warnings.length) {
|
| 407 |
-
warningsHtml += '<div class="mb-3"><strong class="text-amber-700">Conflict Detected:</strong> ' + data.conflict_warnings.join(', ') + '</div>';
|
| 408 |
-
}
|
| 409 |
-
if (data.amendment_notice) {
|
| 410 |
-
warningsHtml += '<div class="mb-3"><strong class="text-blue-700">Amendment Notice:</strong> ' + data.amendment_notice + '</div>';
|
| 411 |
-
}
|
| 412 |
-
if (warningsHtml) {
|
| 413 |
-
warningsDiv.innerHTML = '<div class="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 rounded-xl p-4 text-sm">' + warningsHtml + '</div>';
|
| 414 |
-
warningsDiv.classList.remove('hidden');
|
| 415 |
-
}
|
| 416 |
-
|
| 417 |
-
document.getElementById('stats-row').classList.remove('hidden');
|
| 418 |
-
document.getElementById('confidence-value').textContent = ((data.confidence_score || 0) * 100).toFixed(0) + '%';
|
| 419 |
-
document.getElementById('citations-count').textContent = data.citations ? data.citations.length : 0;
|
| 420 |
-
|
| 421 |
-
const citationsCard = document.getElementById('citations-card');
|
| 422 |
-
const citationsDiv = document.getElementById('citations');
|
| 423 |
-
if (data.citations && data.citations.length) {
|
| 424 |
-
citationsCard.classList.remove('hidden');
|
| 425 |
-
citationsDiv.innerHTML = data.citations.map(c => `
|
| 426 |
-
<div class="citation-card bg-white dark:bg-slate-800 rounded-xl p-4 border-l-4 border-blue-500 dark:border-blue-400 shadow-sm">
|
| 427 |
-
<div class="flex justify-between items-start">
|
| 428 |
-
<div>
|
| 429 |
-
<div class="font-mono text-sm text-blue-600 dark:text-blue-400 font-semibold">${c.section_id}</div>
|
| 430 |
-
<div class="font-medium text-gray-800 dark:text-gray-200">${c.doc_name}</div>
|
| 431 |
-
</div>
|
| 432 |
-
<span class="text-xs bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded-full">${c.jurisdiction}</span>
|
| 433 |
-
</div>
|
| 434 |
-
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">Effective: ${c.effective_date || 'N/A'}</div>
|
| 435 |
-
</div>
|
| 436 |
-
`).join('');
|
| 437 |
-
}
|
| 438 |
-
|
| 439 |
-
} catch (error) {
|
| 440 |
-
document.getElementById('loading').classList.add('hidden');
|
| 441 |
-
document.getElementById('answer-card').classList.remove('hidden');
|
| 442 |
-
document.getElementById('answer').innerHTML = '<div class="text-red-600 dark:text-red-400 p-4 bg-red-50 dark:bg-red-900/30 rounded-xl"><strong>Error:</strong> ' + error.message + '</div>';
|
| 443 |
-
}
|
| 444 |
-
}
|
| 445 |
-
|
| 446 |
-
document.getElementById('query').addEventListener('keypress', function(e) {
|
| 447 |
-
if (e.key === 'Enter') doQuery();
|
| 448 |
-
});
|
| 449 |
-
</script>
|
| 450 |
-
</body>
|
| 451 |
-
</html>
|
| 452 |
-
"""
|
| 453 |
|
| 454 |
|
| 455 |
@asynccontextmanager
|
|
@@ -457,14 +41,33 @@ async def lifespan(app: FastAPI):
|
|
| 457 |
"""Startup and shutdown events."""
|
| 458 |
log.info("civicsetu_starting", env=settings.api_env)
|
| 459 |
|
| 460 |
-
# Pre-compile the graph once at startup — not on first request
|
| 461 |
from civicsetu.agent.graph import get_compiled_graph
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
|
| 469 |
|
| 470 |
def create_app() -> FastAPI:
|
|
@@ -477,13 +80,10 @@ def create_app() -> FastAPI:
|
|
| 477 |
redoc_url="/redoc",
|
| 478 |
)
|
| 479 |
|
| 480 |
-
@app.get("/", include_in_schema=False)
|
| 481 |
-
async def landing_page():
|
| 482 |
-
return HTMLResponse(get_landing_page_html())
|
| 483 |
-
|
| 484 |
app.add_middleware(
|
| 485 |
CORSMiddleware,
|
| 486 |
-
allow_origins=
|
|
|
|
| 487 |
allow_methods=["GET", "POST"],
|
| 488 |
allow_headers=["*"],
|
| 489 |
)
|
|
@@ -491,8 +91,8 @@ def create_app() -> FastAPI:
|
|
| 491 |
|
| 492 |
app.include_router(health.router, tags=["health"])
|
| 493 |
app.include_router(query.router, prefix="/api/v1", tags=["query"])
|
|
|
|
| 494 |
|
| 495 |
return app
|
| 496 |
|
| 497 |
-
|
| 498 |
app = create_app()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
+
import time
|
| 5 |
from contextlib import asynccontextmanager
|
| 6 |
|
| 7 |
import structlog
|
| 8 |
from fastapi import FastAPI
|
|
|
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
|
| 11 |
from civicsetu.api.middleware.logging import LoggingMiddleware
|
| 12 |
+
from civicsetu.api.routes import health, query, graph
|
| 13 |
from civicsetu.config.settings import get_settings
|
| 14 |
+
from civicsetu.stores.graph_store import close_driver, get_driver
|
| 15 |
|
| 16 |
log = structlog.get_logger(__name__)
|
| 17 |
settings = get_settings()
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
def create_checkpointer():
|
| 21 |
+
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
| 22 |
+
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
| 23 |
+
|
| 24 |
+
serde = JsonPlusSerializer(
|
| 25 |
+
allowed_msgpack_modules=[
|
| 26 |
+
("civicsetu.models.schemas", "ChatMessage"),
|
| 27 |
+
("civicsetu.models.schemas", "Citation"),
|
| 28 |
+
("civicsetu.models.schemas", "LegalChunk"),
|
| 29 |
+
("civicsetu.models.schemas", "RetrievedChunk"),
|
| 30 |
+
("civicsetu.models.enums", "ChunkStatus"),
|
| 31 |
+
("civicsetu.models.enums", "DocType"),
|
| 32 |
+
("civicsetu.models.enums", "Jurisdiction"),
|
| 33 |
+
("civicsetu.models.enums", "QueryType"),
|
| 34 |
+
]
|
| 35 |
+
)
|
| 36 |
+
return AsyncPostgresSaver.from_conn_string(settings.postgres_conninfo, serde=serde)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
@asynccontextmanager
|
|
|
|
| 41 |
"""Startup and shutdown events."""
|
| 42 |
log.info("civicsetu_starting", env=settings.api_env)
|
| 43 |
|
|
|
|
| 44 |
from civicsetu.agent.graph import get_compiled_graph
|
| 45 |
+
|
| 46 |
+
async with create_checkpointer() as checkpointer:
|
| 47 |
+
await checkpointer.setup()
|
| 48 |
+
app.state.checkpointer = checkpointer
|
| 49 |
+
app.state.graph = get_compiled_graph(checkpointer=checkpointer)
|
| 50 |
+
log.info("langgraph_compiled", checkpointing=True)
|
| 51 |
+
await get_driver()
|
| 52 |
+
from civicsetu.retrieval import warm_embedding_model
|
| 53 |
+
|
| 54 |
+
warm_start = time.perf_counter()
|
| 55 |
+
await asyncio.to_thread(warm_embedding_model)
|
| 56 |
+
log.info(
|
| 57 |
+
"embedding_model_warmed",
|
| 58 |
+
duration_ms=round((time.perf_counter() - warm_start) * 1000, 2),
|
| 59 |
+
)
|
| 60 |
+
from civicsetu.agent.nodes import _get_ranker
|
| 61 |
+
|
| 62 |
+
ranker_start = time.perf_counter()
|
| 63 |
+
await asyncio.to_thread(_get_ranker)
|
| 64 |
+
log.info(
|
| 65 |
+
"reranker_warmed",
|
| 66 |
+
duration_ms=round((time.perf_counter() - ranker_start) * 1000, 2),
|
| 67 |
+
)
|
| 68 |
+
yield
|
| 69 |
+
await close_driver()
|
| 70 |
+
log.info("civicsetu_shutdown")
|
| 71 |
|
| 72 |
|
| 73 |
def create_app() -> FastAPI:
|
|
|
|
| 80 |
redoc_url="/redoc",
|
| 81 |
)
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
app.add_middleware(
|
| 84 |
CORSMiddleware,
|
| 85 |
+
allow_origins=settings.allowed_origins,
|
| 86 |
+
allow_credentials=True,
|
| 87 |
allow_methods=["GET", "POST"],
|
| 88 |
allow_headers=["*"],
|
| 89 |
)
|
|
|
|
| 91 |
|
| 92 |
app.include_router(health.router, tags=["health"])
|
| 93 |
app.include_router(query.router, prefix="/api/v1", tags=["query"])
|
| 94 |
+
app.include_router(graph.router, prefix="/api/v1", tags=["graph"])
|
| 95 |
|
| 96 |
return app
|
| 97 |
|
|
|
|
| 98 |
app = create_app()
|
src/civicsetu/api/routes/graph.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
import structlog
|
| 8 |
+
from fastapi import APIRouter, HTTPException, Path, Query, Request
|
| 9 |
+
from sqlalchemy import text
|
| 10 |
+
|
| 11 |
+
from civicsetu.guardrails.input_guard import InputGuard
|
| 12 |
+
from civicsetu.guardrails.output_guard import OutputGuard
|
| 13 |
+
from civicsetu.models.enums import Jurisdiction, QueryType
|
| 14 |
+
from civicsetu.models.schemas import (
|
| 15 |
+
ChatMessage,
|
| 16 |
+
CivicSetuResponse,
|
| 17 |
+
ConnectedSectionOut,
|
| 18 |
+
GraphEdge,
|
| 19 |
+
GraphNode,
|
| 20 |
+
GraphTopologyResponse,
|
| 21 |
+
InsufficientInfoResponse,
|
| 22 |
+
SectionChunkOut,
|
| 23 |
+
SectionContentResponse,
|
| 24 |
+
SectionContextQueryRequest,
|
| 25 |
+
)
|
| 26 |
+
from civicsetu.stores.graph_store import GraphStore
|
| 27 |
+
from civicsetu.stores.relational_store import AsyncSessionLocal
|
| 28 |
+
|
| 29 |
+
log = structlog.get_logger(__name__)
|
| 30 |
+
router = APIRouter()
|
| 31 |
+
|
| 32 |
+
_topo_cache: dict = {"data": None, "ts": 0.0}
|
| 33 |
+
_TOPO_TTL = 300
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/graph/topology", response_model=GraphTopologyResponse)
|
| 37 |
+
async def get_graph_topology() -> GraphTopologyResponse:
|
| 38 |
+
"""All connected Section nodes and REFERENCES/DERIVED_FROM edges."""
|
| 39 |
+
now = time.monotonic()
|
| 40 |
+
if _topo_cache["data"] is not None and (now - _topo_cache["ts"]) < _TOPO_TTL:
|
| 41 |
+
log.info("topology_cache_hit")
|
| 42 |
+
return _topo_cache["data"]
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
nodes_raw, edges_raw = await GraphStore.get_topology()
|
| 46 |
+
stats = await GraphStore.graph_stats()
|
| 47 |
+
except Exception as e:
|
| 48 |
+
log.error("topology_fetch_failed", error=str(e))
|
| 49 |
+
raise HTTPException(status_code=500, detail=f"Graph topology fetch failed: {e}")
|
| 50 |
+
|
| 51 |
+
response = GraphTopologyResponse(
|
| 52 |
+
nodes=[GraphNode(**n) for n in nodes_raw],
|
| 53 |
+
edges=[GraphEdge(**e) for e in edges_raw],
|
| 54 |
+
stats=stats,
|
| 55 |
+
)
|
| 56 |
+
_topo_cache["data"] = response
|
| 57 |
+
_topo_cache["ts"] = now
|
| 58 |
+
log.info("topology_cache_updated", nodes=len(response.nodes), edges=len(response.edges))
|
| 59 |
+
return response
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@router.get("/graph/section/{section_id:path}", response_model=SectionContentResponse)
|
| 63 |
+
async def get_section_content(
|
| 64 |
+
section_id: str = Path(..., description="Section ID, e.g. 'Section 18'"),
|
| 65 |
+
jurisdiction: str = Query(..., description="Jurisdiction enum value, e.g. 'CENTRAL'"),
|
| 66 |
+
chunk_id: str | None = Query(default=None, description="Optional chunk id from the graph node"),
|
| 67 |
+
) -> SectionContentResponse:
|
| 68 |
+
"""Stitched section text from Postgres plus connected sections from Neo4j."""
|
| 69 |
+
async with AsyncSessionLocal() as db:
|
| 70 |
+
resolved_section_id = section_id
|
| 71 |
+
resolved_jurisdiction = jurisdiction
|
| 72 |
+
|
| 73 |
+
if chunk_id:
|
| 74 |
+
node_result = await db.execute(
|
| 75 |
+
text(
|
| 76 |
+
"""
|
| 77 |
+
SELECT
|
| 78 |
+
section_id,
|
| 79 |
+
jurisdiction
|
| 80 |
+
FROM legal_chunks
|
| 81 |
+
WHERE chunk_id::text = :chunk_id
|
| 82 |
+
AND lower(status) = 'active'
|
| 83 |
+
LIMIT 1
|
| 84 |
+
"""
|
| 85 |
+
),
|
| 86 |
+
{"chunk_id": chunk_id},
|
| 87 |
+
)
|
| 88 |
+
node_row = node_result.mappings().first()
|
| 89 |
+
if node_row:
|
| 90 |
+
resolved_section_id = node_row["section_id"]
|
| 91 |
+
resolved_jurisdiction = node_row["jurisdiction"]
|
| 92 |
+
|
| 93 |
+
result = await db.execute(
|
| 94 |
+
text(
|
| 95 |
+
"""
|
| 96 |
+
SELECT
|
| 97 |
+
chunk_id::text AS chunk_id,
|
| 98 |
+
section_id,
|
| 99 |
+
section_title,
|
| 100 |
+
text,
|
| 101 |
+
page_number,
|
| 102 |
+
doc_name,
|
| 103 |
+
jurisdiction,
|
| 104 |
+
effective_date,
|
| 105 |
+
source_url
|
| 106 |
+
FROM legal_chunks
|
| 107 |
+
WHERE section_id = :section_id
|
| 108 |
+
AND jurisdiction = :jurisdiction
|
| 109 |
+
AND lower(status) = 'active'
|
| 110 |
+
ORDER BY page_number ASC
|
| 111 |
+
"""
|
| 112 |
+
),
|
| 113 |
+
{"section_id": resolved_section_id, "jurisdiction": resolved_jurisdiction},
|
| 114 |
+
)
|
| 115 |
+
rows = result.mappings().all()
|
| 116 |
+
|
| 117 |
+
if not rows:
|
| 118 |
+
raise HTTPException(
|
| 119 |
+
status_code=404,
|
| 120 |
+
detail=(
|
| 121 |
+
f"No chunks found for section_id='{section_id}' jurisdiction='{jurisdiction}'"
|
| 122 |
+
+ (f" chunk_id='{chunk_id}'" if chunk_id else "")
|
| 123 |
+
),
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
first = rows[0]
|
| 127 |
+
chunks = [
|
| 128 |
+
SectionChunkOut(chunk_id=row["chunk_id"], text=row["text"], page_number=row["page_number"])
|
| 129 |
+
for row in rows
|
| 130 |
+
]
|
| 131 |
+
|
| 132 |
+
refs_out, refs_in, derived_out, derived_in = await asyncio.gather(
|
| 133 |
+
GraphStore.get_referenced_sections(resolved_section_id, resolved_jurisdiction),
|
| 134 |
+
GraphStore.get_sections_referencing(resolved_section_id, resolved_jurisdiction),
|
| 135 |
+
GraphStore.get_derived_act_sections(resolved_section_id, resolved_jurisdiction),
|
| 136 |
+
GraphStore.get_deriving_rule_sections(resolved_section_id, resolved_jurisdiction),
|
| 137 |
+
return_exceptions=True,
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
def _safe_connections(result: object, edge_type: str) -> list[ConnectedSectionOut]:
|
| 141 |
+
if isinstance(result, Exception):
|
| 142 |
+
log.warning("connected_sections_partial_failure", edge_type=edge_type, error=str(result))
|
| 143 |
+
return []
|
| 144 |
+
return [
|
| 145 |
+
ConnectedSectionOut(
|
| 146 |
+
section_id=row["section_id"],
|
| 147 |
+
title=row.get("title", ""),
|
| 148 |
+
jurisdiction=row.get("jurisdiction", resolved_jurisdiction),
|
| 149 |
+
edge_type=edge_type,
|
| 150 |
+
)
|
| 151 |
+
for row in result # type: ignore[union-attr]
|
| 152 |
+
]
|
| 153 |
+
|
| 154 |
+
connected = (
|
| 155 |
+
_safe_connections(refs_out, "REFERENCES_OUT")
|
| 156 |
+
+ _safe_connections(refs_in, "REFERENCES_IN")
|
| 157 |
+
+ _safe_connections(derived_out, "DERIVED_FROM_OUT")
|
| 158 |
+
+ _safe_connections(derived_in, "DERIVED_FROM_IN")
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
log.info(
|
| 162 |
+
"section_content_fetched",
|
| 163 |
+
section_id=resolved_section_id,
|
| 164 |
+
jurisdiction=resolved_jurisdiction,
|
| 165 |
+
chunks=len(chunks),
|
| 166 |
+
connected=len(connected),
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
return SectionContentResponse(
|
| 170 |
+
section_id=first.get("section_id", resolved_section_id),
|
| 171 |
+
title=first["section_title"],
|
| 172 |
+
doc_name=first["doc_name"],
|
| 173 |
+
jurisdiction=resolved_jurisdiction,
|
| 174 |
+
effective_date=str(first["effective_date"]) if first["effective_date"] else None,
|
| 175 |
+
source_url=first["source_url"],
|
| 176 |
+
chunks=chunks,
|
| 177 |
+
connected_sections=connected,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@router.post("/query/section-context", response_model=CivicSetuResponse | InsufficientInfoResponse)
|
| 182 |
+
async def section_context_query(
|
| 183 |
+
request: Request,
|
| 184 |
+
body: SectionContextQueryRequest,
|
| 185 |
+
) -> CivicSetuResponse | InsufficientInfoResponse:
|
| 186 |
+
"""Bypass the classifier and route directly into graph retrieval for a chosen section."""
|
| 187 |
+
guard_result = InputGuard.check(body.query)
|
| 188 |
+
if not guard_result.is_safe:
|
| 189 |
+
log.info("section_context_rejected", reason=guard_result.reason)
|
| 190 |
+
raise HTTPException(status_code=400, detail=guard_result.reason)
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
jurisdiction = Jurisdiction(body.jurisdiction)
|
| 194 |
+
except ValueError:
|
| 195 |
+
raise HTTPException(status_code=422, detail=f"Invalid jurisdiction: '{body.jurisdiction}'")
|
| 196 |
+
|
| 197 |
+
safe_query = guard_result.sanitized_query
|
| 198 |
+
session_id = body.session_id or str(uuid.uuid4())
|
| 199 |
+
graph = request.app.state.graph
|
| 200 |
+
config = {"configurable": {"thread_id": session_id}}
|
| 201 |
+
|
| 202 |
+
initial_state = {
|
| 203 |
+
"query": safe_query,
|
| 204 |
+
"session_id": session_id,
|
| 205 |
+
"jurisdiction_filter": jurisdiction,
|
| 206 |
+
"top_k": 5,
|
| 207 |
+
"messages": [ChatMessage(role="user", content=safe_query)],
|
| 208 |
+
"source_section_id": body.section_id,
|
| 209 |
+
"query_type": QueryType.CROSS_REFERENCE,
|
| 210 |
+
"rewritten_query": safe_query,
|
| 211 |
+
"skip_classifier": True,
|
| 212 |
+
"retrieved_chunks": [],
|
| 213 |
+
"reranked_chunks": [],
|
| 214 |
+
"raw_response": None,
|
| 215 |
+
"citations": [],
|
| 216 |
+
"confidence_score": 0.0,
|
| 217 |
+
"conflict_warnings": [],
|
| 218 |
+
"amendment_notice": None,
|
| 219 |
+
"retry_count": 0,
|
| 220 |
+
"hallucination_flag": False,
|
| 221 |
+
"error": None,
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
try:
|
| 225 |
+
invoke_start = time.perf_counter()
|
| 226 |
+
result = await asyncio.to_thread(graph.invoke, initial_state, config)
|
| 227 |
+
log.info(
|
| 228 |
+
"graph_invoke_complete",
|
| 229 |
+
route="section_context",
|
| 230 |
+
duration_ms=round((time.perf_counter() - invoke_start) * 1000, 2),
|
| 231 |
+
)
|
| 232 |
+
except Exception as e:
|
| 233 |
+
log.error("section_context_invoke_failed", error=str(e))
|
| 234 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 235 |
+
|
| 236 |
+
raw_response = result.get("raw_response")
|
| 237 |
+
if raw_response:
|
| 238 |
+
try:
|
| 239 |
+
update_start = time.perf_counter()
|
| 240 |
+
await asyncio.to_thread(
|
| 241 |
+
graph.update_state,
|
| 242 |
+
config,
|
| 243 |
+
{"messages": [ChatMessage(role="assistant", content=raw_response)]},
|
| 244 |
+
)
|
| 245 |
+
log.info(
|
| 246 |
+
"graph_update_state_complete",
|
| 247 |
+
route="section_context",
|
| 248 |
+
duration_ms=round((time.perf_counter() - update_start) * 1000, 2),
|
| 249 |
+
)
|
| 250 |
+
except Exception as e:
|
| 251 |
+
log.warning("section_context_update_state_failed", error=str(e))
|
| 252 |
+
|
| 253 |
+
output_start = time.perf_counter()
|
| 254 |
+
result["session_id"] = session_id
|
| 255 |
+
response = OutputGuard.process(result, original_query=body.query)
|
| 256 |
+
log.info(
|
| 257 |
+
"output_guard_complete",
|
| 258 |
+
route="section_context",
|
| 259 |
+
duration_ms=round((time.perf_counter() - output_start) * 1000, 2),
|
| 260 |
+
)
|
| 261 |
+
return response
|
src/civicsetu/api/routes/query.py
CHANGED
|
@@ -1,18 +1,15 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
|
|
|
|
|
|
| 4 |
|
| 5 |
import structlog
|
| 6 |
from fastapi import APIRouter, HTTPException, Request
|
| 7 |
|
| 8 |
from civicsetu.guardrails.input_guard import InputGuard
|
| 9 |
from civicsetu.guardrails.output_guard import OutputGuard
|
| 10 |
-
from civicsetu.models.
|
| 11 |
-
from civicsetu.models.schemas import (
|
| 12 |
-
CivicSetuResponse,
|
| 13 |
-
InsufficientInfoResponse,
|
| 14 |
-
QueryRequest,
|
| 15 |
-
)
|
| 16 |
|
| 17 |
log = structlog.get_logger(__name__)
|
| 18 |
router = APIRouter()
|
|
@@ -20,23 +17,22 @@ router = APIRouter()
|
|
| 20 |
|
| 21 |
@router.post("/query", response_model=CivicSetuResponse | InsufficientInfoResponse)
|
| 22 |
async def query_endpoint(request: Request, body: QueryRequest):
|
| 23 |
-
# ── 1. Input guard ────────────────────────────────────────────────────────
|
| 24 |
guard_result = InputGuard.check(body.query)
|
| 25 |
if not guard_result.is_safe:
|
| 26 |
log.info("query_rejected_by_input_guard", reason=guard_result.reason)
|
| 27 |
raise HTTPException(status_code=400, detail=guard_result.reason)
|
| 28 |
|
| 29 |
-
# Use sanitized query downstream (strips whitespace, no PII)
|
| 30 |
safe_query = guard_result.sanitized_query
|
| 31 |
-
|
| 32 |
-
# ── 2. Build initial state ────────────────────────────────────────────────
|
| 33 |
graph = request.app.state.graph
|
|
|
|
| 34 |
|
| 35 |
initial_state = {
|
| 36 |
"query": safe_query,
|
| 37 |
-
"session_id":
|
| 38 |
"jurisdiction_filter": body.jurisdiction_filter,
|
| 39 |
"top_k": body.top_k,
|
|
|
|
| 40 |
"query_type": None,
|
| 41 |
"rewritten_query": None,
|
| 42 |
"retrieved_chunks": [],
|
|
@@ -51,12 +47,41 @@ async def query_endpoint(request: Request, body: QueryRequest):
|
|
| 51 |
"error": None,
|
| 52 |
}
|
| 53 |
|
| 54 |
-
# ── 3. Invoke graph ───────────────────────────────────────────────────────
|
| 55 |
try:
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
except Exception as e:
|
| 58 |
log.error("graph_invoke_failed", error=str(e))
|
| 59 |
raise HTTPException(status_code=500, detail=str(e))
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
|
| 7 |
import structlog
|
| 8 |
from fastapi import APIRouter, HTTPException, Request
|
| 9 |
|
| 10 |
from civicsetu.guardrails.input_guard import InputGuard
|
| 11 |
from civicsetu.guardrails.output_guard import OutputGuard
|
| 12 |
+
from civicsetu.models.schemas import CivicSetuResponse, ChatMessage, InsufficientInfoResponse, QueryRequest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
log = structlog.get_logger(__name__)
|
| 15 |
router = APIRouter()
|
|
|
|
| 17 |
|
| 18 |
@router.post("/query", response_model=CivicSetuResponse | InsufficientInfoResponse)
|
| 19 |
async def query_endpoint(request: Request, body: QueryRequest):
|
|
|
|
| 20 |
guard_result = InputGuard.check(body.query)
|
| 21 |
if not guard_result.is_safe:
|
| 22 |
log.info("query_rejected_by_input_guard", reason=guard_result.reason)
|
| 23 |
raise HTTPException(status_code=400, detail=guard_result.reason)
|
| 24 |
|
|
|
|
| 25 |
safe_query = guard_result.sanitized_query
|
| 26 |
+
session_id = body.session_id or str(uuid.uuid4())
|
|
|
|
| 27 |
graph = request.app.state.graph
|
| 28 |
+
config = {"configurable": {"thread_id": session_id}}
|
| 29 |
|
| 30 |
initial_state = {
|
| 31 |
"query": safe_query,
|
| 32 |
+
"session_id": session_id,
|
| 33 |
"jurisdiction_filter": body.jurisdiction_filter,
|
| 34 |
"top_k": body.top_k,
|
| 35 |
+
"messages": [ChatMessage(role="user", content=safe_query)],
|
| 36 |
"query_type": None,
|
| 37 |
"rewritten_query": None,
|
| 38 |
"retrieved_chunks": [],
|
|
|
|
| 47 |
"error": None,
|
| 48 |
}
|
| 49 |
|
|
|
|
| 50 |
try:
|
| 51 |
+
invoke_start = time.perf_counter()
|
| 52 |
+
result = await asyncio.to_thread(graph.invoke, initial_state, config)
|
| 53 |
+
log.info(
|
| 54 |
+
"graph_invoke_complete",
|
| 55 |
+
route="query",
|
| 56 |
+
duration_ms=round((time.perf_counter() - invoke_start) * 1000, 2),
|
| 57 |
+
)
|
| 58 |
except Exception as e:
|
| 59 |
log.error("graph_invoke_failed", error=str(e))
|
| 60 |
raise HTTPException(status_code=500, detail=str(e))
|
| 61 |
|
| 62 |
+
raw_response = result.get("raw_response")
|
| 63 |
+
if raw_response:
|
| 64 |
+
try:
|
| 65 |
+
update_start = time.perf_counter()
|
| 66 |
+
await asyncio.to_thread(
|
| 67 |
+
graph.update_state,
|
| 68 |
+
config,
|
| 69 |
+
{"messages": [ChatMessage(role="assistant", content=raw_response)]},
|
| 70 |
+
)
|
| 71 |
+
log.info(
|
| 72 |
+
"graph_update_state_complete",
|
| 73 |
+
route="query",
|
| 74 |
+
duration_ms=round((time.perf_counter() - update_start) * 1000, 2),
|
| 75 |
+
)
|
| 76 |
+
except Exception as e:
|
| 77 |
+
log.warning("graph_update_state_failed", error=str(e))
|
| 78 |
+
|
| 79 |
+
output_start = time.perf_counter()
|
| 80 |
+
result["session_id"] = session_id
|
| 81 |
+
response = OutputGuard.process(result, original_query=body.query)
|
| 82 |
+
log.info(
|
| 83 |
+
"output_guard_complete",
|
| 84 |
+
route="query",
|
| 85 |
+
duration_ms=round((time.perf_counter() - output_start) * 1000, 2),
|
| 86 |
+
)
|
| 87 |
+
return response
|
src/civicsetu/config/settings.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
|
|
| 1 |
from functools import lru_cache
|
| 2 |
|
| 3 |
-
from pydantic import Field
|
| 4 |
-
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
|
| 6 |
|
| 7 |
class Settings(BaseSettings):
|
|
@@ -28,6 +29,10 @@ class Settings(BaseSettings):
|
|
| 28 |
default="openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
| 29 |
alias="FALLBACK_MODEL_2",
|
| 30 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
local_model: str = Field(default="ollama/mistral", alias="LOCAL_MODEL")
|
| 32 |
|
| 33 |
# Embeddings
|
|
@@ -54,6 +59,10 @@ class Settings(BaseSettings):
|
|
| 54 |
api_host: str = Field(default="0.0.0.0", alias="API_HOST")
|
| 55 |
api_port: int = Field(default=8000, alias="API_PORT")
|
| 56 |
api_env: str = Field(default="development", alias="API_ENV")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
# Observability
|
| 59 |
phoenix_host: str = Field(default="localhost", alias="PHOENIX_HOST")
|
|
@@ -66,6 +75,15 @@ class Settings(BaseSettings):
|
|
| 66 |
)
|
| 67 |
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
@property
|
| 70 |
def postgres_dsn(self) -> str:
|
| 71 |
return (
|
|
@@ -73,6 +91,13 @@ class Settings(BaseSettings):
|
|
| 73 |
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
| 74 |
)
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
@property
|
| 77 |
def is_production(self) -> bool:
|
| 78 |
return self.api_env == "production"
|
|
|
|
| 1 |
+
from typing import Annotated
|
| 2 |
from functools import lru_cache
|
| 3 |
|
| 4 |
+
from pydantic import Field, field_validator
|
| 5 |
+
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
| 6 |
|
| 7 |
|
| 8 |
class Settings(BaseSettings):
|
|
|
|
| 29 |
default="openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
| 30 |
alias="FALLBACK_MODEL_2",
|
| 31 |
)
|
| 32 |
+
fallback_model_3: str = Field(
|
| 33 |
+
default="openrouter/qwen/qwen3.6-plus:free",
|
| 34 |
+
alias="FALLBACK_MODEL_3",
|
| 35 |
+
)
|
| 36 |
local_model: str = Field(default="ollama/mistral", alias="LOCAL_MODEL")
|
| 37 |
|
| 38 |
# Embeddings
|
|
|
|
| 59 |
api_host: str = Field(default="0.0.0.0", alias="API_HOST")
|
| 60 |
api_port: int = Field(default=8000, alias="API_PORT")
|
| 61 |
api_env: str = Field(default="development", alias="API_ENV")
|
| 62 |
+
allowed_origins: Annotated[list[str], NoDecode] = Field(
|
| 63 |
+
default=["http://localhost:3000"],
|
| 64 |
+
alias="ALLOWED_ORIGINS",
|
| 65 |
+
)
|
| 66 |
|
| 67 |
# Observability
|
| 68 |
phoenix_host: str = Field(default="localhost", alias="PHOENIX_HOST")
|
|
|
|
| 75 |
)
|
| 76 |
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
| 77 |
|
| 78 |
+
@field_validator("allowed_origins", mode="before")
|
| 79 |
+
@classmethod
|
| 80 |
+
def parse_allowed_origins(cls, value: str | list[str]) -> list[str]:
|
| 81 |
+
if isinstance(value, list):
|
| 82 |
+
return value
|
| 83 |
+
if isinstance(value, str):
|
| 84 |
+
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
| 85 |
+
return value
|
| 86 |
+
|
| 87 |
@property
|
| 88 |
def postgres_dsn(self) -> str:
|
| 89 |
return (
|
|
|
|
| 91 |
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
| 92 |
)
|
| 93 |
|
| 94 |
+
@property
|
| 95 |
+
def postgres_conninfo(self) -> str:
|
| 96 |
+
return (
|
| 97 |
+
f"postgresql://{self.postgres_user}:{self.postgres_password}"
|
| 98 |
+
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
@property
|
| 102 |
def is_production(self) -> bool:
|
| 103 |
return self.api_env == "production"
|
src/civicsetu/guardrails/output_guard.py
CHANGED
|
@@ -44,7 +44,10 @@ class OutputGuard:
|
|
| 44 |
# 1 — No citations → always InsufficientInfoResponse
|
| 45 |
if not citations:
|
| 46 |
log.info("output_guard_no_citations", query_preview=original_query[:80])
|
| 47 |
-
return InsufficientInfoResponse(
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
# 2 — Confidence below floor → downgrade
|
| 50 |
if confidence < _CONFIDENCE_FLOOR:
|
|
@@ -54,7 +57,10 @@ class OutputGuard:
|
|
| 54 |
floor=_CONFIDENCE_FLOOR,
|
| 55 |
query_preview=original_query[:80],
|
| 56 |
)
|
| 57 |
-
return InsufficientInfoResponse(
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
# 3 — Build response (disclaimer is a default field on CivicSetuResponse,
|
| 60 |
# but we enforce it here explicitly so no future refactor silently drops it)
|
|
@@ -66,6 +72,7 @@ class OutputGuard:
|
|
| 66 |
query_type_resolved=result.get("query_type") or QueryType.FACT_LOOKUP,
|
| 67 |
conflict_warnings=result.get("conflict_warnings", []),
|
| 68 |
amendment_notice=result.get("amendment_notice"),
|
|
|
|
| 69 |
)
|
| 70 |
|
| 71 |
# Sanity check — disclaimer must survive serialization unchanged
|
|
|
|
| 44 |
# 1 — No citations → always InsufficientInfoResponse
|
| 45 |
if not citations:
|
| 46 |
log.info("output_guard_no_citations", query_preview=original_query[:80])
|
| 47 |
+
return InsufficientInfoResponse(
|
| 48 |
+
searched_query=original_query,
|
| 49 |
+
session_id=result.get("session_id"),
|
| 50 |
+
)
|
| 51 |
|
| 52 |
# 2 — Confidence below floor → downgrade
|
| 53 |
if confidence < _CONFIDENCE_FLOOR:
|
|
|
|
| 57 |
floor=_CONFIDENCE_FLOOR,
|
| 58 |
query_preview=original_query[:80],
|
| 59 |
)
|
| 60 |
+
return InsufficientInfoResponse(
|
| 61 |
+
searched_query=original_query,
|
| 62 |
+
session_id=result.get("session_id"),
|
| 63 |
+
)
|
| 64 |
|
| 65 |
# 3 — Build response (disclaimer is a default field on CivicSetuResponse,
|
| 66 |
# but we enforce it here explicitly so no future refactor silently drops it)
|
|
|
|
| 72 |
query_type_resolved=result.get("query_type") or QueryType.FACT_LOOKUP,
|
| 73 |
conflict_warnings=result.get("conflict_warnings", []),
|
| 74 |
amendment_notice=result.get("amendment_notice"),
|
| 75 |
+
session_id=result.get("session_id"),
|
| 76 |
)
|
| 77 |
|
| 78 |
# Sanity check — disclaimer must survive serialization unchanged
|
src/civicsetu/models/schemas.py
CHANGED
|
@@ -1,137 +1,200 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from datetime import date, datetime
|
| 4 |
-
from typing import Optional
|
| 5 |
from uuid import UUID, uuid4
|
| 6 |
|
| 7 |
from pydantic import BaseModel, Field, computed_field
|
| 8 |
|
| 9 |
from civicsetu.models.enums import (
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
)
|
| 16 |
|
| 17 |
# ── Ingestion Schemas ──────────────────────────────────────────────────────────
|
| 18 |
|
| 19 |
class LegalChunk(BaseModel):
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
|
| 45 |
|
| 46 |
class IngestedDocument(BaseModel):
|
| 47 |
-
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
|
| 60 |
|
| 61 |
# ── Retrieval Schemas ──────────────────────────────────────────────────────────
|
| 62 |
|
| 63 |
class RetrievedChunk(BaseModel):
|
| 64 |
-
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
|
| 73 |
|
| 74 |
# ── Response Schemas ───────────────────────────────────────────────────────────
|
| 75 |
|
| 76 |
class Citation(BaseModel):
|
| 77 |
-
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
class CivicSetuResponse(BaseModel):
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
class InsufficientInfoResponse(BaseModel):
|
| 112 |
-
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
# ── API Request Schemas ────────────────────────────────────────────────────────
|
| 123 |
|
| 124 |
class QueryRequest(BaseModel):
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
|
| 130 |
|
| 131 |
class IngestRequest(BaseModel):
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from datetime import date, datetime
|
| 4 |
+
from typing import Literal, Optional
|
| 5 |
from uuid import UUID, uuid4
|
| 6 |
|
| 7 |
from pydantic import BaseModel, Field, computed_field
|
| 8 |
|
| 9 |
from civicsetu.models.enums import (
|
| 10 |
+
ChunkStatus,
|
| 11 |
+
ConfidenceLevel,
|
| 12 |
+
DocType,
|
| 13 |
+
Jurisdiction,
|
| 14 |
+
QueryType,
|
| 15 |
)
|
| 16 |
|
| 17 |
# ── Ingestion Schemas ──────────────────────────────────────────────────────────
|
| 18 |
|
| 19 |
class LegalChunk(BaseModel):
|
| 20 |
+
"""Canonical unit produced by the ingestion pipeline.
|
| 21 |
+
Every downstream store (pgvector, Neo4j, Postgres) is populated from this."""
|
| 22 |
+
|
| 23 |
+
chunk_id: UUID = Field(default_factory=uuid4)
|
| 24 |
+
doc_id: UUID
|
| 25 |
+
jurisdiction: Jurisdiction
|
| 26 |
+
doc_type: DocType
|
| 27 |
+
doc_name: str
|
| 28 |
+
section_id: str # e.g. "Section 18", "Rule 12"
|
| 29 |
+
section_title: str
|
| 30 |
+
section_hierarchy: list[str] # e.g. ["Part IV", "Chapter 2", "Section 18"]
|
| 31 |
+
text: str
|
| 32 |
+
effective_date: date | None = None
|
| 33 |
+
superseded_by: UUID | None = None
|
| 34 |
+
status: ChunkStatus = ChunkStatus.ACTIVE
|
| 35 |
+
source_url: str
|
| 36 |
+
page_number: int
|
| 37 |
+
embedding: list[float] | None = None # populated after embedding step
|
| 38 |
+
|
| 39 |
+
@computed_field
|
| 40 |
+
@property
|
| 41 |
+
def citation_label(self) -> str:
|
| 42 |
+
"""Human-readable citation string."""
|
| 43 |
+
return f"{self.doc_name} — {self.section_id}"
|
| 44 |
|
| 45 |
|
| 46 |
class IngestedDocument(BaseModel):
|
| 47 |
+
"""Metadata record for a fully ingested document."""
|
| 48 |
|
| 49 |
+
doc_id: UUID = Field(default_factory=uuid4)
|
| 50 |
+
doc_name: str
|
| 51 |
+
jurisdiction: Jurisdiction
|
| 52 |
+
doc_type: DocType
|
| 53 |
+
source_url: str
|
| 54 |
+
effective_date: date | None = None
|
| 55 |
+
gazette_number: str | None = None
|
| 56 |
+
total_chunks: int = 0
|
| 57 |
+
ingested_at: datetime = Field(default_factory=datetime.utcnow)
|
| 58 |
+
is_active: bool = True
|
| 59 |
|
| 60 |
|
| 61 |
# ── Retrieval Schemas ──────────────────────────────────────────────────────────
|
| 62 |
|
| 63 |
class RetrievedChunk(BaseModel):
|
| 64 |
+
"""A LegalChunk enriched with retrieval scores."""
|
| 65 |
|
| 66 |
+
chunk: LegalChunk
|
| 67 |
+
vector_score: float | None = None # cosine similarity from pgvector
|
| 68 |
+
rerank_score: float | None = None # cross-encoder score from FlashRank
|
| 69 |
+
retrieval_source: str = "vector" # "vector" | "graph" | "metadata"
|
| 70 |
+
graph_path: Optional[str] = None
|
| 71 |
+
is_pinned: bool = False
|
| 72 |
|
| 73 |
|
| 74 |
# ── Response Schemas ───────────────────────────────────────────────────────────
|
| 75 |
|
| 76 |
class Citation(BaseModel):
|
| 77 |
+
"""Traceable citation attached to every answer — non-negotiable."""
|
| 78 |
|
| 79 |
+
section_id: str
|
| 80 |
+
doc_name: str
|
| 81 |
+
jurisdiction: Jurisdiction
|
| 82 |
+
effective_date: date | None
|
| 83 |
+
source_url: str
|
| 84 |
+
chunk_id: UUID
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class ChatMessage(BaseModel):
|
| 88 |
+
"""A single conversational message."""
|
| 89 |
+
|
| 90 |
+
role: Literal["user", "assistant"]
|
| 91 |
+
content: str
|
| 92 |
|
| 93 |
|
| 94 |
class CivicSetuResponse(BaseModel):
|
| 95 |
+
"""The immutable public response contract. Shape never changes between phases."""
|
| 96 |
+
|
| 97 |
+
answer: str
|
| 98 |
+
citations: list[Citation] = Field(min_length=1)
|
| 99 |
+
confidence_score: float = Field(ge=0.0, le=1.0)
|
| 100 |
+
query_type_resolved: QueryType
|
| 101 |
+
conflict_warnings: list[str] = Field(default_factory=list)
|
| 102 |
+
amendment_notice: str | None = None
|
| 103 |
+
session_id: str | None = None
|
| 104 |
+
disclaimer: str = (
|
| 105 |
+
"This is AI-generated information, not legal advice. "
|
| 106 |
+
"Consult a qualified lawyer for your specific situation."
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
@computed_field
|
| 110 |
+
@property
|
| 111 |
+
def confidence_level(self) -> ConfidenceLevel:
|
| 112 |
+
if self.confidence_score >= 0.75:
|
| 113 |
+
return ConfidenceLevel.HIGH
|
| 114 |
+
elif self.confidence_score >= 0.50:
|
| 115 |
+
return ConfidenceLevel.MEDIUM
|
| 116 |
+
return ConfidenceLevel.LOW
|
| 117 |
|
| 118 |
|
| 119 |
class InsufficientInfoResponse(BaseModel):
|
| 120 |
+
"""Returned when no citation can be found. Prevents hallucinated answers."""
|
| 121 |
|
| 122 |
+
answer: str = "Insufficient information found in indexed documents."
|
| 123 |
+
searched_query: str
|
| 124 |
+
session_id: str | None = None
|
| 125 |
+
disclaimer: str = (
|
| 126 |
+
"This is AI-generated information, not legal advice. "
|
| 127 |
+
"Consult a qualified lawyer for your specific situation."
|
| 128 |
+
)
|
| 129 |
|
| 130 |
|
| 131 |
# ── API Request Schemas ────────────────────────────────────────────────────────
|
| 132 |
|
| 133 |
class QueryRequest(BaseModel):
|
| 134 |
+
query: str = Field(min_length=5, max_length=1000)
|
| 135 |
+
session_id: str | None = None
|
| 136 |
+
jurisdiction_filter: Jurisdiction | None = None
|
| 137 |
+
top_k: int = Field(default=5, ge=1, le=20)
|
| 138 |
|
| 139 |
|
| 140 |
class IngestRequest(BaseModel):
|
| 141 |
+
source_url: str
|
| 142 |
+
doc_name: str
|
| 143 |
+
jurisdiction: Jurisdiction
|
| 144 |
+
doc_type: DocType
|
| 145 |
+
effective_date: date | None = None
|
| 146 |
+
gazette_number: str | None = None
|
| 147 |
+
|
| 148 |
+
# ── Graph API Schemas ──────────────────────────────────────────────────────────
|
| 149 |
+
|
| 150 |
+
class GraphNode(BaseModel):
|
| 151 |
+
chunk_id: str
|
| 152 |
+
section_id: str
|
| 153 |
+
title: str
|
| 154 |
+
jurisdiction: str
|
| 155 |
+
doc_name: str
|
| 156 |
+
is_active: bool
|
| 157 |
+
connection_count: int
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class GraphEdge(BaseModel):
|
| 161 |
+
source: str # chunk_id
|
| 162 |
+
target: str # chunk_id
|
| 163 |
+
edge_type: str # "REFERENCES" | "DERIVED_FROM"
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class GraphTopologyResponse(BaseModel):
|
| 167 |
+
nodes: list[GraphNode]
|
| 168 |
+
edges: list[GraphEdge]
|
| 169 |
+
stats: dict
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class SectionChunkOut(BaseModel):
|
| 173 |
+
chunk_id: str
|
| 174 |
+
text: str
|
| 175 |
+
page_number: int
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class ConnectedSectionOut(BaseModel):
|
| 179 |
+
section_id: str
|
| 180 |
+
title: str
|
| 181 |
+
jurisdiction: str
|
| 182 |
+
edge_type: str # REFERENCES_OUT | REFERENCES_IN | DERIVED_FROM_OUT | DERIVED_FROM_IN
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class SectionContentResponse(BaseModel):
|
| 186 |
+
section_id: str
|
| 187 |
+
title: str
|
| 188 |
+
doc_name: str
|
| 189 |
+
jurisdiction: str
|
| 190 |
+
effective_date: str | None
|
| 191 |
+
source_url: str
|
| 192 |
+
chunks: list[SectionChunkOut]
|
| 193 |
+
connected_sections: list[ConnectedSectionOut]
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
class SectionContextQueryRequest(BaseModel):
|
| 197 |
+
query: str = Field(min_length=5, max_length=1000)
|
| 198 |
+
section_id: str
|
| 199 |
+
jurisdiction: str
|
| 200 |
+
session_id: str | None = None
|
src/civicsetu/prompts/generator.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
GENERATOR_PROMPT = """Answer the following legal question using ONLY the provided context.
|
| 2 |
|
| 3 |
Question: {query}
|
| 4 |
|
|
@@ -7,7 +7,7 @@ Context:
|
|
| 7 |
|
| 8 |
Respond with JSON:
|
| 9 |
{{
|
| 10 |
-
"answer": "",
|
| 11 |
"confidence_score": <0.0 to 1.0 based on how well context supports the answer>,
|
| 12 |
"cited_chunks": [<1-based indices of ONLY the context items you actually used in your answer>],
|
| 13 |
"amendment_notice": null,
|
|
@@ -16,6 +16,9 @@ Respond with JSON:
|
|
| 16 |
|
| 17 |
Rules:
|
| 18 |
- Only use information present in the context above
|
|
|
|
|
|
|
|
|
|
| 19 |
- Reference specific section numbers in your answer (e.g. "Section 18", "Rule 3")
|
| 20 |
- In cited_chunks, list ONLY the [N] indices you directly drew from — not every item in context
|
| 21 |
- If you used [1] and [3] but not [2], [4], [5] → cited_chunks: [1, 3]
|
|
@@ -23,4 +26,4 @@ Rules:
|
|
| 23 |
- Never invent section numbers or legal provisions
|
| 24 |
- conflict_warnings: list any direct contradictions found between provisions across jurisdictions
|
| 25 |
- amendment_notice: note if any provision appears superseded or amended, otherwise null
|
| 26 |
-
"""
|
|
|
|
| 1 |
+
GENERATOR_PROMPT = """{conversation_history_block}Answer the following legal question using ONLY the provided context.
|
| 2 |
|
| 3 |
Question: {query}
|
| 4 |
|
|
|
|
| 7 |
|
| 8 |
Respond with JSON:
|
| 9 |
{{
|
| 10 |
+
"answer": "<markdown-formatted answer>",
|
| 11 |
"confidence_score": <0.0 to 1.0 based on how well context supports the answer>,
|
| 12 |
"cited_chunks": [<1-based indices of ONLY the context items you actually used in your answer>],
|
| 13 |
"amendment_notice": null,
|
|
|
|
| 16 |
|
| 17 |
Rules:
|
| 18 |
- Only use information present in the context above
|
| 19 |
+
- Format the answer field as GitHub-flavored Markdown
|
| 20 |
+
- Prefer concise markdown paragraphs and bullet lists; use tables only when comparing provisions or jurisdictions
|
| 21 |
+
- Do not wrap the answer in markdown code fences
|
| 22 |
- Reference specific section numbers in your answer (e.g. "Section 18", "Rule 3")
|
| 23 |
- In cited_chunks, list ONLY the [N] indices you directly drew from — not every item in context
|
| 24 |
- If you used [1] and [3] but not [2], [4], [5] → cited_chunks: [1, 3]
|
|
|
|
| 26 |
- Never invent section numbers or legal provisions
|
| 27 |
- conflict_warnings: list any direct contradictions found between provisions across jurisdictions
|
| 28 |
- amendment_notice: note if any provision appears superseded or amended, otherwise null
|
| 29 |
+
"""
|
src/civicsetu/retrieval/__init__.py
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import structlog
|
| 4 |
+
|
| 5 |
+
from civicsetu.ingestion.embedder import Embedder
|
| 6 |
+
from civicsetu.retrieval.cache import embedding_cache, make_key
|
| 7 |
+
|
| 8 |
+
log = structlog.get_logger(__name__)
|
| 9 |
+
_embedder = Embedder()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def cached_embed(query: str) -> list[float]:
|
| 13 |
+
"""Embed query text with a short-lived in-process cache."""
|
| 14 |
+
key = make_key(query)
|
| 15 |
+
cached = embedding_cache.get(key)
|
| 16 |
+
if cached is not None:
|
| 17 |
+
log.debug("embedding_cache_hit", query=query[:60])
|
| 18 |
+
return cached
|
| 19 |
+
|
| 20 |
+
log.debug("embedding_cache_miss", query=query[:60])
|
| 21 |
+
embedding = _embedder.embed_query(query)
|
| 22 |
+
embedding_cache[key] = embedding
|
| 23 |
+
return embedding
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def warm_embedding_model() -> None:
|
| 27 |
+
"""Load the embedding model during startup instead of the first user request."""
|
| 28 |
+
cached_embed("warmup")
|
src/civicsetu/retrieval/cache.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
|
| 5 |
+
from cachetools import TTLCache
|
| 6 |
+
|
| 7 |
+
embedding_cache: TTLCache = TTLCache(maxsize=512, ttl=3600)
|
| 8 |
+
retrieval_cache: TTLCache = TTLCache(maxsize=256, ttl=900)
|
| 9 |
+
graph_cache: TTLCache = TTLCache(maxsize=256, ttl=900)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def make_key(*parts: object) -> str:
|
| 13 |
+
"""Return a stable SHA-256 cache key for normalized stringified parts."""
|
| 14 |
+
normalized = "|".join(str(part).strip().lower() for part in parts)
|
| 15 |
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
src/civicsetu/retrieval/graph_retriever.py
CHANGED
|
@@ -5,6 +5,7 @@ import structlog
|
|
| 5 |
|
| 6 |
from civicsetu.models.enums import Jurisdiction
|
| 7 |
from civicsetu.models.schemas import RetrievedChunk
|
|
|
|
| 8 |
from civicsetu.stores.graph_store import GraphStore
|
| 9 |
from civicsetu.stores.relational_store import AsyncSessionLocal
|
| 10 |
from civicsetu.stores.vector_store import VectorStore
|
|
@@ -48,13 +49,26 @@ class GraphRetriever:
|
|
| 48 |
query: str,
|
| 49 |
jurisdiction: Jurisdiction | None = None,
|
| 50 |
depth: int = 2,
|
|
|
|
| 51 |
) -> list[RetrievedChunk]:
|
| 52 |
-
section_id = GraphRetriever._extract_section_id(query)
|
| 53 |
|
| 54 |
if not section_id:
|
| 55 |
log.info("graph_retriever_no_section_found", query=query[:80])
|
| 56 |
return []
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
# Explicit filter → search only that jurisdiction.
|
| 59 |
# No filter → search all jurisdictions (CENTRAL first).
|
| 60 |
jurisdictions_to_search = (
|
|
@@ -168,6 +182,7 @@ class GraphRetriever:
|
|
| 168 |
pinned=len(pinned),
|
| 169 |
deduped_total=len(deduped),
|
| 170 |
)
|
|
|
|
| 171 |
return final
|
| 172 |
|
| 173 |
@staticmethod
|
|
|
|
| 5 |
|
| 6 |
from civicsetu.models.enums import Jurisdiction
|
| 7 |
from civicsetu.models.schemas import RetrievedChunk
|
| 8 |
+
from civicsetu.retrieval.cache import graph_cache, make_key
|
| 9 |
from civicsetu.stores.graph_store import GraphStore
|
| 10 |
from civicsetu.stores.relational_store import AsyncSessionLocal
|
| 11 |
from civicsetu.stores.vector_store import VectorStore
|
|
|
|
| 49 |
query: str,
|
| 50 |
jurisdiction: Jurisdiction | None = None,
|
| 51 |
depth: int = 2,
|
| 52 |
+
source_section_id: str | None = None,
|
| 53 |
) -> list[RetrievedChunk]:
|
| 54 |
+
section_id = source_section_id or GraphRetriever._extract_section_id(query)
|
| 55 |
|
| 56 |
if not section_id:
|
| 57 |
log.info("graph_retriever_no_section_found", query=query[:80])
|
| 58 |
return []
|
| 59 |
|
| 60 |
+
jurisdiction_key = jurisdiction.value if jurisdiction else "all"
|
| 61 |
+
cache_key = make_key(section_id, jurisdiction_key, depth)
|
| 62 |
+
cached = graph_cache.get(cache_key)
|
| 63 |
+
if cached is not None:
|
| 64 |
+
log.debug(
|
| 65 |
+
"graph_retrieval_cache_hit",
|
| 66 |
+
section_id=section_id,
|
| 67 |
+
jurisdiction=jurisdiction_key,
|
| 68 |
+
depth=depth,
|
| 69 |
+
)
|
| 70 |
+
return cached
|
| 71 |
+
|
| 72 |
# Explicit filter → search only that jurisdiction.
|
| 73 |
# No filter → search all jurisdictions (CENTRAL first).
|
| 74 |
jurisdictions_to_search = (
|
|
|
|
| 182 |
pinned=len(pinned),
|
| 183 |
deduped_total=len(deduped),
|
| 184 |
)
|
| 185 |
+
graph_cache[cache_key] = final
|
| 186 |
return final
|
| 187 |
|
| 188 |
@staticmethod
|
src/civicsetu/stores/graph_store.py
CHANGED
|
@@ -384,6 +384,38 @@ class GraphStore:
|
|
| 384 |
)
|
| 385 |
return await result.data()
|
| 386 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
@staticmethod
|
| 388 |
async def graph_stats() -> dict:
|
| 389 |
driver = await _get_driver()
|
|
@@ -400,3 +432,4 @@ class GraphStore:
|
|
| 400 |
)
|
| 401 |
record = await result.single()
|
| 402 |
return dict(record) if record else {}
|
|
|
|
|
|
| 384 |
)
|
| 385 |
return await result.data()
|
| 386 |
|
| 387 |
+
@staticmethod
|
| 388 |
+
async def get_topology() -> tuple[list[dict], list[dict]]:
|
| 389 |
+
driver = await _get_driver()
|
| 390 |
+
async with driver.session() as session:
|
| 391 |
+
edges_result = await session.run(
|
| 392 |
+
"""
|
| 393 |
+
MATCH (s:Section)-[r]->(t:Section)
|
| 394 |
+
WHERE type(r) IN ['REFERENCES', 'DERIVED_FROM']
|
| 395 |
+
AND s.is_active = true AND t.is_active = true
|
| 396 |
+
RETURN s.chunk_id AS source, t.chunk_id AS target, type(r) AS edge_type
|
| 397 |
+
"""
|
| 398 |
+
)
|
| 399 |
+
nodes_result = await session.run(
|
| 400 |
+
"""
|
| 401 |
+
MATCH (s:Section)-[r]-()
|
| 402 |
+
WHERE type(r) IN ['REFERENCES', 'DERIVED_FROM']
|
| 403 |
+
AND s.is_active = true
|
| 404 |
+
WITH s, count(r) AS conn_count
|
| 405 |
+
RETURN DISTINCT
|
| 406 |
+
s.chunk_id AS chunk_id,
|
| 407 |
+
s.section_id AS section_id,
|
| 408 |
+
s.title AS title,
|
| 409 |
+
s.jurisdiction AS jurisdiction,
|
| 410 |
+
s.doc_name AS doc_name,
|
| 411 |
+
s.is_active AS is_active,
|
| 412 |
+
conn_count AS connection_count
|
| 413 |
+
"""
|
| 414 |
+
)
|
| 415 |
+
edges = await edges_result.data()
|
| 416 |
+
nodes = await nodes_result.data()
|
| 417 |
+
return nodes, edges
|
| 418 |
+
|
| 419 |
@staticmethod
|
| 420 |
async def graph_stats() -> dict:
|
| 421 |
driver = await _get_driver()
|
|
|
|
| 432 |
)
|
| 433 |
record = await result.single()
|
| 434 |
return dict(record) if record else {}
|
| 435 |
+
|
src/civicsetu/stores/vector_store.py
CHANGED
|
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
| 9 |
from civicsetu.config.settings import get_settings
|
| 10 |
from civicsetu.models.enums import DocType, Jurisdiction
|
| 11 |
from civicsetu.models.schemas import LegalChunk, RetrievedChunk
|
|
|
|
| 12 |
|
| 13 |
log = structlog.get_logger(__name__)
|
| 14 |
settings = get_settings()
|
|
@@ -36,6 +37,17 @@ class VectorStore:
|
|
| 36 |
f"got {len(query_embedding)}"
|
| 37 |
)
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
# Build dynamic WHERE clause
|
| 40 |
filters = []
|
| 41 |
params: dict = {
|
|
@@ -101,6 +113,7 @@ class VectorStore:
|
|
| 101 |
results=len(retrieved),
|
| 102 |
jurisdiction=jurisdiction,
|
| 103 |
)
|
|
|
|
| 104 |
return retrieved
|
| 105 |
|
| 106 |
@staticmethod
|
|
|
|
| 9 |
from civicsetu.config.settings import get_settings
|
| 10 |
from civicsetu.models.enums import DocType, Jurisdiction
|
| 11 |
from civicsetu.models.schemas import LegalChunk, RetrievedChunk
|
| 12 |
+
from civicsetu.retrieval.cache import make_key, retrieval_cache
|
| 13 |
|
| 14 |
log = structlog.get_logger(__name__)
|
| 15 |
settings = get_settings()
|
|
|
|
| 37 |
f"got {len(query_embedding)}"
|
| 38 |
)
|
| 39 |
|
| 40 |
+
cache_key = make_key(str(query_embedding), jurisdiction, doc_type, top_k, active_only)
|
| 41 |
+
cached = retrieval_cache.get(cache_key)
|
| 42 |
+
if cached is not None:
|
| 43 |
+
log.debug(
|
| 44 |
+
"vector_retrieval_cache_hit",
|
| 45 |
+
jurisdiction=str(jurisdiction) if jurisdiction else None,
|
| 46 |
+
doc_type=str(doc_type) if doc_type else None,
|
| 47 |
+
top_k=top_k,
|
| 48 |
+
)
|
| 49 |
+
return cached
|
| 50 |
+
|
| 51 |
# Build dynamic WHERE clause
|
| 52 |
filters = []
|
| 53 |
params: dict = {
|
|
|
|
| 113 |
results=len(retrieved),
|
| 114 |
jurisdiction=jurisdiction,
|
| 115 |
)
|
| 116 |
+
retrieval_cache[cache_key] = retrieved
|
| 117 |
return retrieved
|
| 118 |
|
| 119 |
@staticmethod
|
tests/conftest.py
CHANGED
|
@@ -6,7 +6,7 @@ from datetime import date
|
|
| 6 |
import pytest
|
| 7 |
|
| 8 |
from civicsetu.models.enums import ChunkStatus, DocType, Jurisdiction, QueryType
|
| 9 |
-
from civicsetu.models.schemas import Citation, LegalChunk, RetrievedChunk
|
| 10 |
|
| 11 |
|
| 12 |
def _make_chunk(
|
|
@@ -55,6 +55,7 @@ def _base_state(**overrides) -> dict:
|
|
| 55 |
"session_id": "test-session",
|
| 56 |
"jurisdiction_filter": None,
|
| 57 |
"top_k": 5,
|
|
|
|
| 58 |
"query_type": None,
|
| 59 |
"rewritten_query": None,
|
| 60 |
"retrieved_chunks": [],
|
|
|
|
| 6 |
import pytest
|
| 7 |
|
| 8 |
from civicsetu.models.enums import ChunkStatus, DocType, Jurisdiction, QueryType
|
| 9 |
+
from civicsetu.models.schemas import Citation, ChatMessage, LegalChunk, RetrievedChunk
|
| 10 |
|
| 11 |
|
| 12 |
def _make_chunk(
|
|
|
|
| 55 |
"session_id": "test-session",
|
| 56 |
"jurisdiction_filter": None,
|
| 57 |
"top_k": 5,
|
| 58 |
+
"messages": [ChatMessage(role="user", content="What are promoter obligations?")],
|
| 59 |
"query_type": None,
|
| 60 |
"rewritten_query": None,
|
| 61 |
"retrieved_chunks": [],
|