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
Files changed (50) hide show
  1. .gitignore +20 -0
  2. Makefile +21 -2
  3. README.md +1 -0
  4. frontend/.env.production +1 -0
  5. frontend/next-env.d.ts +6 -0
  6. frontend/next.config.ts +14 -0
  7. frontend/package-lock.json +0 -0
  8. frontend/package.json +33 -0
  9. frontend/postcss.config.js +6 -0
  10. frontend/src/app/globals.css +86 -0
  11. frontend/src/app/layout.tsx +43 -0
  12. frontend/src/app/page.tsx +132 -0
  13. frontend/src/components/ChatThread.tsx +73 -0
  14. frontend/src/components/CitationsPanel.tsx +64 -0
  15. frontend/src/components/ConfidenceBadge.tsx +31 -0
  16. frontend/src/components/Header.tsx +83 -0
  17. frontend/src/components/InputBar.tsx +171 -0
  18. frontend/src/components/MessageBubble.tsx +109 -0
  19. frontend/src/components/ThemeProvider.tsx +8 -0
  20. frontend/src/components/graph/ContextPill.tsx +26 -0
  21. frontend/src/components/graph/ForceGraph.tsx +274 -0
  22. frontend/src/components/graph/GraphExplorer.tsx +132 -0
  23. frontend/src/components/graph/GraphFilterSidebar.tsx +155 -0
  24. frontend/src/components/graph/NodeInfoCard.tsx +112 -0
  25. frontend/src/components/graph/SectionDrawer.tsx +133 -0
  26. frontend/src/hooks/useChat.ts +99 -0
  27. frontend/src/hooks/useGraphExplorer.ts +102 -0
  28. frontend/src/lib/api.ts +92 -0
  29. frontend/src/lib/constants.ts +44 -0
  30. frontend/src/lib/types.ts +122 -0
  31. frontend/tailwind.config.ts +31 -0
  32. frontend/tsconfig.json +27 -0
  33. frontend/tsconfig.tsbuildinfo +0 -0
  34. pyproject.toml +2 -0
  35. src/civicsetu/agent/graph.py +21 -21
  36. src/civicsetu/agent/nodes.py +144 -42
  37. src/civicsetu/agent/state.py +6 -3
  38. src/civicsetu/api/main.py +50 -450
  39. src/civicsetu/api/routes/graph.py +261 -0
  40. src/civicsetu/api/routes/query.py +40 -15
  41. src/civicsetu/config/settings.py +27 -2
  42. src/civicsetu/guardrails/output_guard.py +9 -2
  43. src/civicsetu/models/schemas.py +156 -93
  44. src/civicsetu/prompts/generator.py +6 -3
  45. src/civicsetu/retrieval/__init__.py +28 -0
  46. src/civicsetu/retrieval/cache.py +15 -0
  47. src/civicsetu/retrieval/graph_retriever.py +16 -1
  48. src/civicsetu/stores/graph_store.py +33 -0
  49. src/civicsetu/stores/vector_store.py +13 -0
  50. 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' : ''}`}>&#x25BC;</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
+ &#x25BC;
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
+ -&gt;
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
+ &#x25BC;
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
- # ── Register nodes ─────────────────────────────────────────────────────────
36
- graph.add_node("classifier", classifier_node)
37
- graph.add_node("vector_retrieval", vector_retrieval_node)
38
- graph.add_node("graph_retrieval", graph_retrieval_node)
39
- graph.add_node("reranker", reranker_node)
40
- graph.add_node("generator", generator_node)
41
- graph.add_node("validator", validator_node)
42
- graph.add_node("retry", _retry_node)
43
- graph.add_node("hybrid_retrieval", hybrid_retrieval_node)
44
-
45
- # ── Entry point ────────────────────────────────────────────────────────────
46
- graph.set_entry_point("classifier")
47
 
48
- # ── Edges ──────────────────────────────────────────────────────────────────
 
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,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.prompts.validator import VALIDATOR_PROMPT
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
- _embedder = Embedder()
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
- return response.choices[0].message.content.strip()
 
 
 
 
 
 
 
 
 
 
 
 
55
  except Exception as e:
56
- log.warning("llm_fallback", model=model, error=str(e))
 
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
- query_embedding = _embedder.embed_query(query)
 
 
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
- query_embedding = _embedder.embed_query(query)
 
 
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 Ranker, RerankRequest
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
- ranker = Ranker(model_name="ms-marco-MiniLM-L-12-v2", cache_dir=".cache/flashrank")
 
 
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
- prompt = GENERATOR_PROMPT.format(query=query, context=context)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("generator_node", query=query[:80], context_chunks=len(chunks))
 
 
 
 
 
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
- # Build context with section metadata — same format as generator_node
405
- # so the validator can verify citations like "Section 11(1)" actually exist
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
- query_embedding = _embedder.embed_query(query)
 
 
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: Annotated[list[RetrievedChunk], operator.add]
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 get_driver, close_driver
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
- <main class="max-w-6xl mx-auto px-6 py-12">
145
- <div class="text-center mb-12">
146
- <h2 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
147
- AI-Powered <span class="gradient-text">RERA Research</span>
148
- </h2>
149
- <p class="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto leading-relaxed">
150
- Query Indian real estate regulations across 5 jurisdictions. Get cited, structured answers
151
- with cross-reference traversal and conflict detection — powered by LangGraph agents.
152
- </p>
153
- </div>
154
-
155
- <div class="glass rounded-3xl shadow-2xl p-8 mb-8 border border-white/30 dark:border-slate-700/30">
156
- <div class="flex flex-col lg:flex-row gap-4 mb-6">
157
- <div class="flex-1 relative">
158
- <svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
159
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
160
- </svg>
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
- app.state.graph = get_compiled_graph()
463
- log.info("langgraph_compiled")
464
- await get_driver()
465
- yield
466
- await close_driver()
467
- log.info("civicsetu_shutdown")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=["*"] if not settings.is_production else [],
 
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.enums import QueryType
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": body.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
- result = await asyncio.to_thread(graph.invoke, initial_state)
 
 
 
 
 
 
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
- # ── 4. Output guard ───────────────────────────────────────────────────────
62
- return OutputGuard.process(result, original_query=body.query)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(searched_query=original_query)
 
 
 
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(searched_query=original_query)
 
 
 
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
- 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 CivicSetuResponse(BaseModel):
88
- """The immutable public response contract. Shape never changes between phases."""
89
-
90
- answer: str
91
- citations: list[Citation] = Field(min_length=1)
92
- confidence_score: float = Field(ge=0.0, le=1.0)
93
- query_type_resolved: QueryType
94
- conflict_warnings: list[str] = Field(default_factory=list)
95
- amendment_notice: str | None = None
96
- disclaimer: str = (
97
- "This is AI-generated information, not legal advice. "
98
- "Consult a qualified lawyer for your specific situation."
99
- )
100
-
101
- @computed_field
102
- @property
103
- def confidence_level(self) -> ConfidenceLevel:
104
- if self.confidence_score >= 0.75:
105
- return ConfidenceLevel.HIGH
106
- elif self.confidence_score >= 0.50:
107
- return ConfidenceLevel.MEDIUM
108
- return ConfidenceLevel.LOW
 
109
 
110
 
111
  class InsufficientInfoResponse(BaseModel):
112
- """Returned when no citation can be found. Prevents hallucinated answers."""
113
 
114
- answer: str = "Insufficient information found in indexed documents."
115
- searched_query: str
116
- disclaimer: str = (
117
- "This is AI-generated information, not legal advice. "
118
- "Consult a qualified lawyer for your specific situation."
119
- )
 
120
 
121
 
122
  # ── API Request Schemas ────────────────────────────────────────────────────────
123
 
124
  class QueryRequest(BaseModel):
125
- query: str = Field(min_length=5, max_length=1000)
126
- session_id: str | None = None
127
- jurisdiction_filter: Jurisdiction | None = None
128
- top_k: int = Field(default=5, ge=1, le=20)
129
 
130
 
131
  class IngestRequest(BaseModel):
132
- source_url: str
133
- doc_name: str
134
- jurisdiction: Jurisdiction
135
- doc_type: DocType
136
- effective_date: date | None = None
137
- gazette_number: str | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": [],