yugbirla commited on
Commit
b33072f
·
1 Parent(s): 43ce592

Add product database foundation

Browse files
app/main.py CHANGED
@@ -1,3 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from app.evaluation.graphrag_batch_evaluator import run_graphrag_batch_evaluation
2
  from app.evaluation.graph_fusion_evaluator import compare_graph_fusion_retrieval
3
  from app.graph.graph_guided_retriever import graph_guided_retrieve
@@ -79,7 +98,7 @@ def health_check():
79
  "message": f"{settings.APP_NAME} backend is alive",
80
  "environment": settings.ENVIRONMENT,
81
  "version": settings.APP_VERSION,
82
- "phase": "Phase 20 - GraphRAG Batch Evaluation Report"
83
  }
84
 
85
 
@@ -539,3 +558,90 @@ def evaluate_graph_fusion_batch_for_document(
539
  @app.get("/graphrag-demo", response_class=HTMLResponse)
540
  def graphrag_demo_page():
541
  return get_graphrag_demo_html()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from app.product.product_db import (
3
+ init_product_database,
4
+ get_database_status,
5
+ upsert_user,
6
+ list_users,
7
+ register_document_record,
8
+ list_documents,
9
+ create_conversation,
10
+ list_conversations,
11
+ add_message,
12
+ list_messages
13
+ )
14
+ from app.product.product_schema import (
15
+ CreateLocalUserRequest,
16
+ RegisterDocumentRequest,
17
+ CreateConversationRequest,
18
+ AddMessageRequest
19
+ )
20
  from app.evaluation.graphrag_batch_evaluator import run_graphrag_batch_evaluation
21
  from app.evaluation.graph_fusion_evaluator import compare_graph_fusion_retrieval
22
  from app.graph.graph_guided_retriever import graph_guided_retrieve
 
98
  "message": f"{settings.APP_NAME} backend is alive",
99
  "environment": settings.ENVIRONMENT,
100
  "version": settings.APP_VERSION,
101
+ "phase": "Phase 23 - Product Database Foundation"
102
  }
103
 
104
 
 
558
  @app.get("/graphrag-demo", response_class=HTMLResponse)
559
  def graphrag_demo_page():
560
  return get_graphrag_demo_html()
561
+
562
+
563
+ # Product database foundation endpoints
564
+
565
+ @app.on_event("startup")
566
+ def initialize_product_database_on_startup():
567
+ init_product_database()
568
+
569
+
570
+ @app.get("/product/db/status")
571
+ def product_database_status():
572
+ return get_database_status()
573
+
574
+
575
+ @app.post("/product/users/local")
576
+ def create_or_update_local_user(request: CreateLocalUserRequest):
577
+ user_id = "local_" + request.email.lower().replace("@", "_").replace(".", "_")
578
+
579
+ return upsert_user(
580
+ user_id=user_id,
581
+ email=request.email,
582
+ name=request.name,
583
+ role=request.role,
584
+ auth_provider="local"
585
+ )
586
+
587
+
588
+ @app.get("/product/users")
589
+ def get_product_users(limit: int = Query(100, ge=1, le=500)):
590
+ return {
591
+ "users": list_users(limit=limit)
592
+ }
593
+
594
+
595
+ @app.post("/product/documents/register")
596
+ def register_product_document(request: RegisterDocumentRequest):
597
+ return register_document_record(
598
+ document_id=request.document_id,
599
+ source_file_name=request.source_file_name,
600
+ owner_user_id=request.owner_user_id
601
+ )
602
+
603
+
604
+ @app.get("/product/documents")
605
+ def get_product_documents(limit: int = Query(100, ge=1, le=500)):
606
+ return {
607
+ "documents": list_documents(limit=limit)
608
+ }
609
+
610
+
611
+ @app.post("/product/conversations")
612
+ def create_product_conversation(request: CreateConversationRequest):
613
+ conversation_id = str(uuid.uuid4())
614
+
615
+ return create_conversation(
616
+ conversation_id=conversation_id,
617
+ owner_user_id=request.owner_user_id,
618
+ document_id=request.document_id,
619
+ title=request.title
620
+ )
621
+
622
+
623
+ @app.get("/product/conversations")
624
+ def get_product_conversations(limit: int = Query(100, ge=1, le=500)):
625
+ return {
626
+ "conversations": list_conversations(limit=limit)
627
+ }
628
+
629
+
630
+ @app.post("/product/messages")
631
+ def add_product_message(request: AddMessageRequest):
632
+ message_id = str(uuid.uuid4())
633
+
634
+ return add_message(
635
+ message_id=message_id,
636
+ conversation_id=request.conversation_id,
637
+ role=request.role,
638
+ content=request.content
639
+ )
640
+
641
+
642
+ @app.get("/product/conversations/{conversation_id}/messages")
643
+ def get_product_conversation_messages(conversation_id: str):
644
+ return {
645
+ "conversation_id": conversation_id,
646
+ "messages": list_messages(conversation_id=conversation_id)
647
+ }
app/product/__init__.py ADDED
File without changes
app/product/product_db.py ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import sqlite3
4
+ from pathlib import Path
5
+ from datetime import datetime, timezone
6
+ from typing import Dict, Any, List, Optional
7
+
8
+ from app.core.config import settings
9
+
10
+
11
+ def utc_now() -> str:
12
+ return datetime.now(timezone.utc).isoformat()
13
+
14
+
15
+ def get_database_path() -> Path:
16
+ """
17
+ Uses APP_DATABASE_PATH if provided.
18
+ Otherwise stores DB near processed data.
19
+
20
+ On Hugging Face free Spaces, this may be temporary unless persistent storage is attached.
21
+ """
22
+ env_path = os.getenv("APP_DATABASE_PATH")
23
+
24
+ if env_path:
25
+ db_path = Path(env_path)
26
+ else:
27
+ db_path = settings.PROCESSED_DIR.parent / "product_app.sqlite3"
28
+
29
+ db_path.parent.mkdir(parents=True, exist_ok=True)
30
+ return db_path
31
+
32
+
33
+ def get_connection():
34
+ db_path = get_database_path()
35
+ conn = sqlite3.connect(str(db_path))
36
+ conn.row_factory = sqlite3.Row
37
+ return conn
38
+
39
+
40
+ def row_to_dict(row) -> Dict[str, Any]:
41
+ if row is None:
42
+ return {}
43
+
44
+ return dict(row)
45
+
46
+
47
+ def rows_to_dicts(rows) -> List[Dict[str, Any]]:
48
+ return [dict(row) for row in rows]
49
+
50
+
51
+ def init_product_database() -> Dict[str, Any]:
52
+ conn = get_connection()
53
+ cursor = conn.cursor()
54
+
55
+ cursor.execute("""
56
+ CREATE TABLE IF NOT EXISTS users (
57
+ user_id TEXT PRIMARY KEY,
58
+ email TEXT UNIQUE NOT NULL,
59
+ name TEXT,
60
+ role TEXT NOT NULL DEFAULT 'user',
61
+ auth_provider TEXT DEFAULT 'local',
62
+ avatar_url TEXT,
63
+ is_active INTEGER NOT NULL DEFAULT 1,
64
+ created_at TEXT NOT NULL,
65
+ last_login_at TEXT
66
+ )
67
+ """)
68
+
69
+ cursor.execute("""
70
+ CREATE TABLE IF NOT EXISTS user_documents (
71
+ document_id TEXT PRIMARY KEY,
72
+ owner_user_id TEXT,
73
+ source_file_name TEXT,
74
+ upload_status TEXT DEFAULT 'uploaded',
75
+ index_status TEXT DEFAULT 'not_indexed',
76
+ graph_status TEXT DEFAULT 'not_built',
77
+ chunk_count INTEGER DEFAULT 0,
78
+ entity_count INTEGER DEFAULT 0,
79
+ relation_count INTEGER DEFAULT 0,
80
+ created_at TEXT NOT NULL,
81
+ indexed_at TEXT,
82
+ graph_built_at TEXT,
83
+ metadata_json TEXT,
84
+ FOREIGN KEY(owner_user_id) REFERENCES users(user_id)
85
+ )
86
+ """)
87
+
88
+ cursor.execute("""
89
+ CREATE TABLE IF NOT EXISTS conversations (
90
+ conversation_id TEXT PRIMARY KEY,
91
+ owner_user_id TEXT,
92
+ document_id TEXT,
93
+ title TEXT,
94
+ created_at TEXT NOT NULL,
95
+ updated_at TEXT NOT NULL,
96
+ FOREIGN KEY(owner_user_id) REFERENCES users(user_id),
97
+ FOREIGN KEY(document_id) REFERENCES user_documents(document_id)
98
+ )
99
+ """)
100
+
101
+ cursor.execute("""
102
+ CREATE TABLE IF NOT EXISTS messages (
103
+ message_id TEXT PRIMARY KEY,
104
+ conversation_id TEXT NOT NULL,
105
+ role TEXT NOT NULL,
106
+ content TEXT NOT NULL,
107
+ created_at TEXT NOT NULL,
108
+ metadata_json TEXT,
109
+ FOREIGN KEY(conversation_id) REFERENCES conversations(conversation_id)
110
+ )
111
+ """)
112
+
113
+ cursor.execute("""
114
+ CREATE TABLE IF NOT EXISTS admin_logs (
115
+ log_id TEXT PRIMARY KEY,
116
+ actor_user_id TEXT,
117
+ action TEXT NOT NULL,
118
+ target_type TEXT,
119
+ target_id TEXT,
120
+ created_at TEXT NOT NULL,
121
+ metadata_json TEXT
122
+ )
123
+ """)
124
+
125
+ cursor.execute("""
126
+ CREATE TABLE IF NOT EXISTS app_settings (
127
+ key TEXT PRIMARY KEY,
128
+ value TEXT,
129
+ updated_at TEXT NOT NULL
130
+ )
131
+ """)
132
+
133
+ conn.commit()
134
+
135
+ tables = get_table_counts(conn)
136
+ conn.close()
137
+
138
+ return {
139
+ "status": "success",
140
+ "message": "Product database initialized.",
141
+ "database_path": str(get_database_path()),
142
+ "tables": tables
143
+ }
144
+
145
+
146
+ def get_table_counts(conn=None) -> Dict[str, int]:
147
+ should_close = False
148
+
149
+ if conn is None:
150
+ conn = get_connection()
151
+ should_close = True
152
+
153
+ cursor = conn.cursor()
154
+
155
+ table_names = [
156
+ "users",
157
+ "user_documents",
158
+ "conversations",
159
+ "messages",
160
+ "admin_logs",
161
+ "app_settings"
162
+ ]
163
+
164
+ counts = {}
165
+
166
+ for table in table_names:
167
+ cursor.execute(f"SELECT COUNT(*) AS count FROM {table}")
168
+ counts[table] = int(cursor.fetchone()["count"])
169
+
170
+ if should_close:
171
+ conn.close()
172
+
173
+ return counts
174
+
175
+
176
+ def get_database_status() -> Dict[str, Any]:
177
+ init_product_database()
178
+
179
+ conn = get_connection()
180
+ counts = get_table_counts(conn)
181
+ conn.close()
182
+
183
+ return {
184
+ "status": "healthy",
185
+ "database_path": str(get_database_path()),
186
+ "table_counts": counts,
187
+ "storage_note": "On free Hugging Face Spaces, SQLite data may reset unless persistent storage is enabled."
188
+ }
189
+
190
+
191
+ def upsert_user(
192
+ user_id: str,
193
+ email: str,
194
+ name: Optional[str] = None,
195
+ role: str = "user",
196
+ auth_provider: str = "local",
197
+ avatar_url: Optional[str] = None
198
+ ) -> Dict[str, Any]:
199
+ init_product_database()
200
+
201
+ now = utc_now()
202
+
203
+ conn = get_connection()
204
+ cursor = conn.cursor()
205
+
206
+ cursor.execute("""
207
+ INSERT INTO users (
208
+ user_id, email, name, role, auth_provider, avatar_url,
209
+ is_active, created_at, last_login_at
210
+ )
211
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
212
+ ON CONFLICT(email) DO UPDATE SET
213
+ name = excluded.name,
214
+ role = excluded.role,
215
+ auth_provider = excluded.auth_provider,
216
+ avatar_url = excluded.avatar_url,
217
+ last_login_at = excluded.last_login_at
218
+ """, (
219
+ user_id,
220
+ email,
221
+ name,
222
+ role,
223
+ auth_provider,
224
+ avatar_url,
225
+ now,
226
+ now
227
+ ))
228
+
229
+ conn.commit()
230
+
231
+ cursor.execute("SELECT * FROM users WHERE email = ?", (email,))
232
+ user = row_to_dict(cursor.fetchone())
233
+
234
+ conn.close()
235
+
236
+ return user
237
+
238
+
239
+ def list_users(limit: int = 100) -> List[Dict[str, Any]]:
240
+ init_product_database()
241
+
242
+ conn = get_connection()
243
+ cursor = conn.cursor()
244
+
245
+ cursor.execute("""
246
+ SELECT user_id, email, name, role, auth_provider, is_active, created_at, last_login_at
247
+ FROM users
248
+ ORDER BY created_at DESC
249
+ LIMIT ?
250
+ """, (limit,))
251
+
252
+ users = rows_to_dicts(cursor.fetchall())
253
+ conn.close()
254
+
255
+ return users
256
+
257
+
258
+ def register_document_record(
259
+ document_id: str,
260
+ source_file_name: Optional[str] = None,
261
+ owner_user_id: Optional[str] = None,
262
+ upload_status: str = "uploaded"
263
+ ) -> Dict[str, Any]:
264
+ init_product_database()
265
+
266
+ now = utc_now()
267
+
268
+ conn = get_connection()
269
+ cursor = conn.cursor()
270
+
271
+ cursor.execute("""
272
+ INSERT INTO user_documents (
273
+ document_id, owner_user_id, source_file_name, upload_status,
274
+ index_status, graph_status, created_at
275
+ )
276
+ VALUES (?, ?, ?, ?, 'not_indexed', 'not_built', ?)
277
+ ON CONFLICT(document_id) DO UPDATE SET
278
+ source_file_name = COALESCE(excluded.source_file_name, user_documents.source_file_name),
279
+ owner_user_id = COALESCE(excluded.owner_user_id, user_documents.owner_user_id),
280
+ upload_status = excluded.upload_status
281
+ """, (
282
+ document_id,
283
+ owner_user_id,
284
+ source_file_name,
285
+ upload_status,
286
+ now
287
+ ))
288
+
289
+ conn.commit()
290
+
291
+ cursor.execute("SELECT * FROM user_documents WHERE document_id = ?", (document_id,))
292
+ document = row_to_dict(cursor.fetchone())
293
+
294
+ conn.close()
295
+
296
+ return document
297
+
298
+
299
+ def update_document_index_status(
300
+ document_id: str,
301
+ index_status: str = "indexed",
302
+ chunk_count: int = 0
303
+ ) -> Dict[str, Any]:
304
+ init_product_database()
305
+
306
+ now = utc_now()
307
+
308
+ conn = get_connection()
309
+ cursor = conn.cursor()
310
+
311
+ cursor.execute("""
312
+ UPDATE user_documents
313
+ SET index_status = ?, chunk_count = ?, indexed_at = ?
314
+ WHERE document_id = ?
315
+ """, (
316
+ index_status,
317
+ chunk_count,
318
+ now,
319
+ document_id
320
+ ))
321
+
322
+ conn.commit()
323
+
324
+ cursor.execute("SELECT * FROM user_documents WHERE document_id = ?", (document_id,))
325
+ document = row_to_dict(cursor.fetchone())
326
+
327
+ conn.close()
328
+
329
+ return document
330
+
331
+
332
+ def update_document_graph_status(
333
+ document_id: str,
334
+ graph_status: str = "built",
335
+ entity_count: int = 0,
336
+ relation_count: int = 0
337
+ ) -> Dict[str, Any]:
338
+ init_product_database()
339
+
340
+ now = utc_now()
341
+
342
+ conn = get_connection()
343
+ cursor = conn.cursor()
344
+
345
+ cursor.execute("""
346
+ UPDATE user_documents
347
+ SET graph_status = ?, entity_count = ?, relation_count = ?, graph_built_at = ?
348
+ WHERE document_id = ?
349
+ """, (
350
+ graph_status,
351
+ entity_count,
352
+ relation_count,
353
+ now,
354
+ document_id
355
+ ))
356
+
357
+ conn.commit()
358
+
359
+ cursor.execute("SELECT * FROM user_documents WHERE document_id = ?", (document_id,))
360
+ document = row_to_dict(cursor.fetchone())
361
+
362
+ conn.close()
363
+
364
+ return document
365
+
366
+
367
+ def list_documents(limit: int = 100) -> List[Dict[str, Any]]:
368
+ init_product_database()
369
+
370
+ conn = get_connection()
371
+ cursor = conn.cursor()
372
+
373
+ cursor.execute("""
374
+ SELECT *
375
+ FROM user_documents
376
+ ORDER BY created_at DESC
377
+ LIMIT ?
378
+ """, (limit,))
379
+
380
+ documents = rows_to_dicts(cursor.fetchall())
381
+ conn.close()
382
+
383
+ return documents
384
+
385
+
386
+ def create_conversation(
387
+ conversation_id: str,
388
+ owner_user_id: Optional[str],
389
+ document_id: Optional[str],
390
+ title: str
391
+ ) -> Dict[str, Any]:
392
+ init_product_database()
393
+
394
+ now = utc_now()
395
+
396
+ conn = get_connection()
397
+ cursor = conn.cursor()
398
+
399
+ cursor.execute("""
400
+ INSERT INTO conversations (
401
+ conversation_id, owner_user_id, document_id, title, created_at, updated_at
402
+ )
403
+ VALUES (?, ?, ?, ?, ?, ?)
404
+ """, (
405
+ conversation_id,
406
+ owner_user_id,
407
+ document_id,
408
+ title,
409
+ now,
410
+ now
411
+ ))
412
+
413
+ conn.commit()
414
+
415
+ cursor.execute("SELECT * FROM conversations WHERE conversation_id = ?", (conversation_id,))
416
+ conversation = row_to_dict(cursor.fetchone())
417
+
418
+ conn.close()
419
+
420
+ return conversation
421
+
422
+
423
+ def list_conversations(limit: int = 100) -> List[Dict[str, Any]]:
424
+ init_product_database()
425
+
426
+ conn = get_connection()
427
+ cursor = conn.cursor()
428
+
429
+ cursor.execute("""
430
+ SELECT *
431
+ FROM conversations
432
+ ORDER BY updated_at DESC
433
+ LIMIT ?
434
+ """, (limit,))
435
+
436
+ conversations = rows_to_dicts(cursor.fetchall())
437
+ conn.close()
438
+
439
+ return conversations
440
+
441
+
442
+ def add_message(
443
+ message_id: str,
444
+ conversation_id: str,
445
+ role: str,
446
+ content: str,
447
+ metadata_json: Optional[str] = None
448
+ ) -> Dict[str, Any]:
449
+ init_product_database()
450
+
451
+ now = utc_now()
452
+
453
+ conn = get_connection()
454
+ cursor = conn.cursor()
455
+
456
+ cursor.execute("""
457
+ INSERT INTO messages (
458
+ message_id, conversation_id, role, content, created_at, metadata_json
459
+ )
460
+ VALUES (?, ?, ?, ?, ?, ?)
461
+ """, (
462
+ message_id,
463
+ conversation_id,
464
+ role,
465
+ content,
466
+ now,
467
+ metadata_json
468
+ ))
469
+
470
+ cursor.execute("""
471
+ UPDATE conversations
472
+ SET updated_at = ?
473
+ WHERE conversation_id = ?
474
+ """, (
475
+ now,
476
+ conversation_id
477
+ ))
478
+
479
+ conn.commit()
480
+
481
+ cursor.execute("SELECT * FROM messages WHERE message_id = ?", (message_id,))
482
+ message = row_to_dict(cursor.fetchone())
483
+
484
+ conn.close()
485
+
486
+ return message
487
+
488
+
489
+ def list_messages(conversation_id: str) -> List[Dict[str, Any]]:
490
+ init_product_database()
491
+
492
+ conn = get_connection()
493
+ cursor = conn.cursor()
494
+
495
+ cursor.execute("""
496
+ SELECT *
497
+ FROM messages
498
+ WHERE conversation_id = ?
499
+ ORDER BY created_at ASC
500
+ """, (conversation_id,))
501
+
502
+ messages = rows_to_dicts(cursor.fetchall())
503
+ conn.close()
504
+
505
+ return messages
app/product/product_schema.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from pydantic import BaseModel, Field
3
+ from typing import Optional
4
+
5
+
6
+ class CreateLocalUserRequest(BaseModel):
7
+ email: str
8
+ name: Optional[str] = None
9
+ role: str = Field(default="user")
10
+
11
+
12
+ class RegisterDocumentRequest(BaseModel):
13
+ document_id: str
14
+ source_file_name: Optional[str] = None
15
+ owner_user_id: Optional[str] = None
16
+
17
+
18
+ class CreateConversationRequest(BaseModel):
19
+ owner_user_id: Optional[str] = None
20
+ document_id: Optional[str] = None
21
+ title: str = "New chat"
22
+
23
+
24
+ class AddMessageRequest(BaseModel):
25
+ conversation_id: str
26
+ role: str = Field(..., pattern="^(user|assistant|system)$")
27
+ content: str
scripts/phase23_product_database_foundation.py ADDED
@@ -0,0 +1,691 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ # Remove BOM from Python files
4
+ for path in Path("app").rglob("*.py"):
5
+ text = path.read_text(encoding="utf-8-sig")
6
+ text = text.replace("\ufeff", "")
7
+ path.write_text(text, encoding="utf-8")
8
+
9
+ print("BOM cleanup completed.")
10
+
11
+
12
+ # =====================================================
13
+ # 1. Product DB module
14
+ # =====================================================
15
+
16
+ Path("app/product/product_db.py").write_text(r'''
17
+ import os
18
+ import sqlite3
19
+ from pathlib import Path
20
+ from datetime import datetime, timezone
21
+ from typing import Dict, Any, List, Optional
22
+
23
+ from app.core.config import settings
24
+
25
+
26
+ def utc_now() -> str:
27
+ return datetime.now(timezone.utc).isoformat()
28
+
29
+
30
+ def get_database_path() -> Path:
31
+ """
32
+ Uses APP_DATABASE_PATH if provided.
33
+ Otherwise stores DB near processed data.
34
+
35
+ On Hugging Face free Spaces, this may be temporary unless persistent storage is attached.
36
+ """
37
+ env_path = os.getenv("APP_DATABASE_PATH")
38
+
39
+ if env_path:
40
+ db_path = Path(env_path)
41
+ else:
42
+ db_path = settings.PROCESSED_DIR.parent / "product_app.sqlite3"
43
+
44
+ db_path.parent.mkdir(parents=True, exist_ok=True)
45
+ return db_path
46
+
47
+
48
+ def get_connection():
49
+ db_path = get_database_path()
50
+ conn = sqlite3.connect(str(db_path))
51
+ conn.row_factory = sqlite3.Row
52
+ return conn
53
+
54
+
55
+ def row_to_dict(row) -> Dict[str, Any]:
56
+ if row is None:
57
+ return {}
58
+
59
+ return dict(row)
60
+
61
+
62
+ def rows_to_dicts(rows) -> List[Dict[str, Any]]:
63
+ return [dict(row) for row in rows]
64
+
65
+
66
+ def init_product_database() -> Dict[str, Any]:
67
+ conn = get_connection()
68
+ cursor = conn.cursor()
69
+
70
+ cursor.execute("""
71
+ CREATE TABLE IF NOT EXISTS users (
72
+ user_id TEXT PRIMARY KEY,
73
+ email TEXT UNIQUE NOT NULL,
74
+ name TEXT,
75
+ role TEXT NOT NULL DEFAULT 'user',
76
+ auth_provider TEXT DEFAULT 'local',
77
+ avatar_url TEXT,
78
+ is_active INTEGER NOT NULL DEFAULT 1,
79
+ created_at TEXT NOT NULL,
80
+ last_login_at TEXT
81
+ )
82
+ """)
83
+
84
+ cursor.execute("""
85
+ CREATE TABLE IF NOT EXISTS user_documents (
86
+ document_id TEXT PRIMARY KEY,
87
+ owner_user_id TEXT,
88
+ source_file_name TEXT,
89
+ upload_status TEXT DEFAULT 'uploaded',
90
+ index_status TEXT DEFAULT 'not_indexed',
91
+ graph_status TEXT DEFAULT 'not_built',
92
+ chunk_count INTEGER DEFAULT 0,
93
+ entity_count INTEGER DEFAULT 0,
94
+ relation_count INTEGER DEFAULT 0,
95
+ created_at TEXT NOT NULL,
96
+ indexed_at TEXT,
97
+ graph_built_at TEXT,
98
+ metadata_json TEXT,
99
+ FOREIGN KEY(owner_user_id) REFERENCES users(user_id)
100
+ )
101
+ """)
102
+
103
+ cursor.execute("""
104
+ CREATE TABLE IF NOT EXISTS conversations (
105
+ conversation_id TEXT PRIMARY KEY,
106
+ owner_user_id TEXT,
107
+ document_id TEXT,
108
+ title TEXT,
109
+ created_at TEXT NOT NULL,
110
+ updated_at TEXT NOT NULL,
111
+ FOREIGN KEY(owner_user_id) REFERENCES users(user_id),
112
+ FOREIGN KEY(document_id) REFERENCES user_documents(document_id)
113
+ )
114
+ """)
115
+
116
+ cursor.execute("""
117
+ CREATE TABLE IF NOT EXISTS messages (
118
+ message_id TEXT PRIMARY KEY,
119
+ conversation_id TEXT NOT NULL,
120
+ role TEXT NOT NULL,
121
+ content TEXT NOT NULL,
122
+ created_at TEXT NOT NULL,
123
+ metadata_json TEXT,
124
+ FOREIGN KEY(conversation_id) REFERENCES conversations(conversation_id)
125
+ )
126
+ """)
127
+
128
+ cursor.execute("""
129
+ CREATE TABLE IF NOT EXISTS admin_logs (
130
+ log_id TEXT PRIMARY KEY,
131
+ actor_user_id TEXT,
132
+ action TEXT NOT NULL,
133
+ target_type TEXT,
134
+ target_id TEXT,
135
+ created_at TEXT NOT NULL,
136
+ metadata_json TEXT
137
+ )
138
+ """)
139
+
140
+ cursor.execute("""
141
+ CREATE TABLE IF NOT EXISTS app_settings (
142
+ key TEXT PRIMARY KEY,
143
+ value TEXT,
144
+ updated_at TEXT NOT NULL
145
+ )
146
+ """)
147
+
148
+ conn.commit()
149
+
150
+ tables = get_table_counts(conn)
151
+ conn.close()
152
+
153
+ return {
154
+ "status": "success",
155
+ "message": "Product database initialized.",
156
+ "database_path": str(get_database_path()),
157
+ "tables": tables
158
+ }
159
+
160
+
161
+ def get_table_counts(conn=None) -> Dict[str, int]:
162
+ should_close = False
163
+
164
+ if conn is None:
165
+ conn = get_connection()
166
+ should_close = True
167
+
168
+ cursor = conn.cursor()
169
+
170
+ table_names = [
171
+ "users",
172
+ "user_documents",
173
+ "conversations",
174
+ "messages",
175
+ "admin_logs",
176
+ "app_settings"
177
+ ]
178
+
179
+ counts = {}
180
+
181
+ for table in table_names:
182
+ cursor.execute(f"SELECT COUNT(*) AS count FROM {table}")
183
+ counts[table] = int(cursor.fetchone()["count"])
184
+
185
+ if should_close:
186
+ conn.close()
187
+
188
+ return counts
189
+
190
+
191
+ def get_database_status() -> Dict[str, Any]:
192
+ init_product_database()
193
+
194
+ conn = get_connection()
195
+ counts = get_table_counts(conn)
196
+ conn.close()
197
+
198
+ return {
199
+ "status": "healthy",
200
+ "database_path": str(get_database_path()),
201
+ "table_counts": counts,
202
+ "storage_note": "On free Hugging Face Spaces, SQLite data may reset unless persistent storage is enabled."
203
+ }
204
+
205
+
206
+ def upsert_user(
207
+ user_id: str,
208
+ email: str,
209
+ name: Optional[str] = None,
210
+ role: str = "user",
211
+ auth_provider: str = "local",
212
+ avatar_url: Optional[str] = None
213
+ ) -> Dict[str, Any]:
214
+ init_product_database()
215
+
216
+ now = utc_now()
217
+
218
+ conn = get_connection()
219
+ cursor = conn.cursor()
220
+
221
+ cursor.execute("""
222
+ INSERT INTO users (
223
+ user_id, email, name, role, auth_provider, avatar_url,
224
+ is_active, created_at, last_login_at
225
+ )
226
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
227
+ ON CONFLICT(email) DO UPDATE SET
228
+ name = excluded.name,
229
+ role = excluded.role,
230
+ auth_provider = excluded.auth_provider,
231
+ avatar_url = excluded.avatar_url,
232
+ last_login_at = excluded.last_login_at
233
+ """, (
234
+ user_id,
235
+ email,
236
+ name,
237
+ role,
238
+ auth_provider,
239
+ avatar_url,
240
+ now,
241
+ now
242
+ ))
243
+
244
+ conn.commit()
245
+
246
+ cursor.execute("SELECT * FROM users WHERE email = ?", (email,))
247
+ user = row_to_dict(cursor.fetchone())
248
+
249
+ conn.close()
250
+
251
+ return user
252
+
253
+
254
+ def list_users(limit: int = 100) -> List[Dict[str, Any]]:
255
+ init_product_database()
256
+
257
+ conn = get_connection()
258
+ cursor = conn.cursor()
259
+
260
+ cursor.execute("""
261
+ SELECT user_id, email, name, role, auth_provider, is_active, created_at, last_login_at
262
+ FROM users
263
+ ORDER BY created_at DESC
264
+ LIMIT ?
265
+ """, (limit,))
266
+
267
+ users = rows_to_dicts(cursor.fetchall())
268
+ conn.close()
269
+
270
+ return users
271
+
272
+
273
+ def register_document_record(
274
+ document_id: str,
275
+ source_file_name: Optional[str] = None,
276
+ owner_user_id: Optional[str] = None,
277
+ upload_status: str = "uploaded"
278
+ ) -> Dict[str, Any]:
279
+ init_product_database()
280
+
281
+ now = utc_now()
282
+
283
+ conn = get_connection()
284
+ cursor = conn.cursor()
285
+
286
+ cursor.execute("""
287
+ INSERT INTO user_documents (
288
+ document_id, owner_user_id, source_file_name, upload_status,
289
+ index_status, graph_status, created_at
290
+ )
291
+ VALUES (?, ?, ?, ?, 'not_indexed', 'not_built', ?)
292
+ ON CONFLICT(document_id) DO UPDATE SET
293
+ source_file_name = COALESCE(excluded.source_file_name, user_documents.source_file_name),
294
+ owner_user_id = COALESCE(excluded.owner_user_id, user_documents.owner_user_id),
295
+ upload_status = excluded.upload_status
296
+ """, (
297
+ document_id,
298
+ owner_user_id,
299
+ source_file_name,
300
+ upload_status,
301
+ now
302
+ ))
303
+
304
+ conn.commit()
305
+
306
+ cursor.execute("SELECT * FROM user_documents WHERE document_id = ?", (document_id,))
307
+ document = row_to_dict(cursor.fetchone())
308
+
309
+ conn.close()
310
+
311
+ return document
312
+
313
+
314
+ def update_document_index_status(
315
+ document_id: str,
316
+ index_status: str = "indexed",
317
+ chunk_count: int = 0
318
+ ) -> Dict[str, Any]:
319
+ init_product_database()
320
+
321
+ now = utc_now()
322
+
323
+ conn = get_connection()
324
+ cursor = conn.cursor()
325
+
326
+ cursor.execute("""
327
+ UPDATE user_documents
328
+ SET index_status = ?, chunk_count = ?, indexed_at = ?
329
+ WHERE document_id = ?
330
+ """, (
331
+ index_status,
332
+ chunk_count,
333
+ now,
334
+ document_id
335
+ ))
336
+
337
+ conn.commit()
338
+
339
+ cursor.execute("SELECT * FROM user_documents WHERE document_id = ?", (document_id,))
340
+ document = row_to_dict(cursor.fetchone())
341
+
342
+ conn.close()
343
+
344
+ return document
345
+
346
+
347
+ def update_document_graph_status(
348
+ document_id: str,
349
+ graph_status: str = "built",
350
+ entity_count: int = 0,
351
+ relation_count: int = 0
352
+ ) -> Dict[str, Any]:
353
+ init_product_database()
354
+
355
+ now = utc_now()
356
+
357
+ conn = get_connection()
358
+ cursor = conn.cursor()
359
+
360
+ cursor.execute("""
361
+ UPDATE user_documents
362
+ SET graph_status = ?, entity_count = ?, relation_count = ?, graph_built_at = ?
363
+ WHERE document_id = ?
364
+ """, (
365
+ graph_status,
366
+ entity_count,
367
+ relation_count,
368
+ now,
369
+ document_id
370
+ ))
371
+
372
+ conn.commit()
373
+
374
+ cursor.execute("SELECT * FROM user_documents WHERE document_id = ?", (document_id,))
375
+ document = row_to_dict(cursor.fetchone())
376
+
377
+ conn.close()
378
+
379
+ return document
380
+
381
+
382
+ def list_documents(limit: int = 100) -> List[Dict[str, Any]]:
383
+ init_product_database()
384
+
385
+ conn = get_connection()
386
+ cursor = conn.cursor()
387
+
388
+ cursor.execute("""
389
+ SELECT *
390
+ FROM user_documents
391
+ ORDER BY created_at DESC
392
+ LIMIT ?
393
+ """, (limit,))
394
+
395
+ documents = rows_to_dicts(cursor.fetchall())
396
+ conn.close()
397
+
398
+ return documents
399
+
400
+
401
+ def create_conversation(
402
+ conversation_id: str,
403
+ owner_user_id: Optional[str],
404
+ document_id: Optional[str],
405
+ title: str
406
+ ) -> Dict[str, Any]:
407
+ init_product_database()
408
+
409
+ now = utc_now()
410
+
411
+ conn = get_connection()
412
+ cursor = conn.cursor()
413
+
414
+ cursor.execute("""
415
+ INSERT INTO conversations (
416
+ conversation_id, owner_user_id, document_id, title, created_at, updated_at
417
+ )
418
+ VALUES (?, ?, ?, ?, ?, ?)
419
+ """, (
420
+ conversation_id,
421
+ owner_user_id,
422
+ document_id,
423
+ title,
424
+ now,
425
+ now
426
+ ))
427
+
428
+ conn.commit()
429
+
430
+ cursor.execute("SELECT * FROM conversations WHERE conversation_id = ?", (conversation_id,))
431
+ conversation = row_to_dict(cursor.fetchone())
432
+
433
+ conn.close()
434
+
435
+ return conversation
436
+
437
+
438
+ def list_conversations(limit: int = 100) -> List[Dict[str, Any]]:
439
+ init_product_database()
440
+
441
+ conn = get_connection()
442
+ cursor = conn.cursor()
443
+
444
+ cursor.execute("""
445
+ SELECT *
446
+ FROM conversations
447
+ ORDER BY updated_at DESC
448
+ LIMIT ?
449
+ """, (limit,))
450
+
451
+ conversations = rows_to_dicts(cursor.fetchall())
452
+ conn.close()
453
+
454
+ return conversations
455
+
456
+
457
+ def add_message(
458
+ message_id: str,
459
+ conversation_id: str,
460
+ role: str,
461
+ content: str,
462
+ metadata_json: Optional[str] = None
463
+ ) -> Dict[str, Any]:
464
+ init_product_database()
465
+
466
+ now = utc_now()
467
+
468
+ conn = get_connection()
469
+ cursor = conn.cursor()
470
+
471
+ cursor.execute("""
472
+ INSERT INTO messages (
473
+ message_id, conversation_id, role, content, created_at, metadata_json
474
+ )
475
+ VALUES (?, ?, ?, ?, ?, ?)
476
+ """, (
477
+ message_id,
478
+ conversation_id,
479
+ role,
480
+ content,
481
+ now,
482
+ metadata_json
483
+ ))
484
+
485
+ cursor.execute("""
486
+ UPDATE conversations
487
+ SET updated_at = ?
488
+ WHERE conversation_id = ?
489
+ """, (
490
+ now,
491
+ conversation_id
492
+ ))
493
+
494
+ conn.commit()
495
+
496
+ cursor.execute("SELECT * FROM messages WHERE message_id = ?", (message_id,))
497
+ message = row_to_dict(cursor.fetchone())
498
+
499
+ conn.close()
500
+
501
+ return message
502
+
503
+
504
+ def list_messages(conversation_id: str) -> List[Dict[str, Any]]:
505
+ init_product_database()
506
+
507
+ conn = get_connection()
508
+ cursor = conn.cursor()
509
+
510
+ cursor.execute("""
511
+ SELECT *
512
+ FROM messages
513
+ WHERE conversation_id = ?
514
+ ORDER BY created_at ASC
515
+ """, (conversation_id,))
516
+
517
+ messages = rows_to_dicts(cursor.fetchall())
518
+ conn.close()
519
+
520
+ return messages
521
+ ''', encoding="utf-8")
522
+
523
+
524
+ # =====================================================
525
+ # 2. Product schemas
526
+ # =====================================================
527
+
528
+ Path("app/product/product_schema.py").write_text(r'''
529
+ from pydantic import BaseModel, Field
530
+ from typing import Optional
531
+
532
+
533
+ class CreateLocalUserRequest(BaseModel):
534
+ email: str
535
+ name: Optional[str] = None
536
+ role: str = Field(default="user")
537
+
538
+
539
+ class RegisterDocumentRequest(BaseModel):
540
+ document_id: str
541
+ source_file_name: Optional[str] = None
542
+ owner_user_id: Optional[str] = None
543
+
544
+
545
+ class CreateConversationRequest(BaseModel):
546
+ owner_user_id: Optional[str] = None
547
+ document_id: Optional[str] = None
548
+ title: str = "New chat"
549
+
550
+
551
+ class AddMessageRequest(BaseModel):
552
+ conversation_id: str
553
+ role: str = Field(..., pattern="^(user|assistant|system)$")
554
+ content: str
555
+ ''', encoding="utf-8")
556
+
557
+
558
+ # =====================================================
559
+ # 3. Patch main.py
560
+ # =====================================================
561
+
562
+ main_path = Path("app/main.py")
563
+ text = main_path.read_text(encoding="utf-8-sig")
564
+ text = text.replace("\ufeff", "")
565
+
566
+ imports = '''import uuid
567
+ from app.product.product_db import (
568
+ init_product_database,
569
+ get_database_status,
570
+ upsert_user,
571
+ list_users,
572
+ register_document_record,
573
+ list_documents,
574
+ create_conversation,
575
+ list_conversations,
576
+ add_message,
577
+ list_messages
578
+ )
579
+ from app.product.product_schema import (
580
+ CreateLocalUserRequest,
581
+ RegisterDocumentRequest,
582
+ CreateConversationRequest,
583
+ AddMessageRequest
584
+ )
585
+ '''
586
+
587
+ if "from app.product.product_db import" not in text:
588
+ text = imports + text
589
+
590
+ old_phases = [
591
+ "Phase 22 - GraphRAG Demo UI Upgrade",
592
+ "Phase 21 - Deployment Sync Verification",
593
+ "Phase 20 - GraphRAG Batch Evaluation Report"
594
+ ]
595
+
596
+ for old in old_phases:
597
+ text = text.replace(old, "Phase 23 - Product Database Foundation")
598
+
599
+ if "# Product database foundation endpoints" not in text:
600
+ text += '''
601
+
602
+ # Product database foundation endpoints
603
+
604
+ @app.on_event("startup")
605
+ def initialize_product_database_on_startup():
606
+ init_product_database()
607
+
608
+
609
+ @app.get("/product/db/status")
610
+ def product_database_status():
611
+ return get_database_status()
612
+
613
+
614
+ @app.post("/product/users/local")
615
+ def create_or_update_local_user(request: CreateLocalUserRequest):
616
+ user_id = "local_" + request.email.lower().replace("@", "_").replace(".", "_")
617
+
618
+ return upsert_user(
619
+ user_id=user_id,
620
+ email=request.email,
621
+ name=request.name,
622
+ role=request.role,
623
+ auth_provider="local"
624
+ )
625
+
626
+
627
+ @app.get("/product/users")
628
+ def get_product_users(limit: int = Query(100, ge=1, le=500)):
629
+ return {
630
+ "users": list_users(limit=limit)
631
+ }
632
+
633
+
634
+ @app.post("/product/documents/register")
635
+ def register_product_document(request: RegisterDocumentRequest):
636
+ return register_document_record(
637
+ document_id=request.document_id,
638
+ source_file_name=request.source_file_name,
639
+ owner_user_id=request.owner_user_id
640
+ )
641
+
642
+
643
+ @app.get("/product/documents")
644
+ def get_product_documents(limit: int = Query(100, ge=1, le=500)):
645
+ return {
646
+ "documents": list_documents(limit=limit)
647
+ }
648
+
649
+
650
+ @app.post("/product/conversations")
651
+ def create_product_conversation(request: CreateConversationRequest):
652
+ conversation_id = str(uuid.uuid4())
653
+
654
+ return create_conversation(
655
+ conversation_id=conversation_id,
656
+ owner_user_id=request.owner_user_id,
657
+ document_id=request.document_id,
658
+ title=request.title
659
+ )
660
+
661
+
662
+ @app.get("/product/conversations")
663
+ def get_product_conversations(limit: int = Query(100, ge=1, le=500)):
664
+ return {
665
+ "conversations": list_conversations(limit=limit)
666
+ }
667
+
668
+
669
+ @app.post("/product/messages")
670
+ def add_product_message(request: AddMessageRequest):
671
+ message_id = str(uuid.uuid4())
672
+
673
+ return add_message(
674
+ message_id=message_id,
675
+ conversation_id=request.conversation_id,
676
+ role=request.role,
677
+ content=request.content
678
+ )
679
+
680
+
681
+ @app.get("/product/conversations/{conversation_id}/messages")
682
+ def get_product_conversation_messages(conversation_id: str):
683
+ return {
684
+ "conversation_id": conversation_id,
685
+ "messages": list_messages(conversation_id=conversation_id)
686
+ }
687
+ '''
688
+
689
+ main_path.write_text(text, encoding="utf-8")
690
+
691
+ print("Phase 23 product database foundation added.")