feat: add info engine MVP pipeline and dashboard page
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m10s
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m10s
Introduce a standalone scheduled info-engine worker with SQLite persistence and expose read-only dashboard APIs/UI so curated intelligence items can be collected and viewed with minimal architecture changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,9 @@ CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://
|
||||
# Chat Summary
|
||||
CHAT_SUMMARY_DB = os.environ.get("CHAT_SUMMARY_DB", "/app/data/chat-summarizer/chat_summary.db")
|
||||
|
||||
# Info Engine
|
||||
INFO_ENGINE_DB = os.environ.get("INFO_ENGINE_DB", "/app/data/info-engine/info_engine.db")
|
||||
|
||||
# LiteLLM
|
||||
LITELLM_URL = os.environ.get("LITELLM_URL", "http://127.0.0.1:4005")
|
||||
LITELLM_HEALTH_API_KEY = os.environ.get("LITELLM_HEALTH_API_KEY", "")
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect
|
||||
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine
|
||||
import auth as auth_module
|
||||
from rbac import require_page, require_write
|
||||
|
||||
@@ -185,6 +185,8 @@ app.include_router(cc_connect.router, prefix="/api/cc-connect",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
||||
app.include_router(chat_summary.router, prefix="/api/chat-summary",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))])
|
||||
app.include_router(info_engine.router, prefix="/api/info-engine",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
||||
app.include_router(security.router, prefix="/api/security",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("security"))])
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import json
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from config import INFO_ENGINE_DB
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _db():
|
||||
return aiosqlite.connect(INFO_ENGINE_DB)
|
||||
|
||||
|
||||
def _row_to_item(row):
|
||||
tags = []
|
||||
if row[5]:
|
||||
try:
|
||||
tags = json.loads(row[5])
|
||||
except Exception:
|
||||
tags = []
|
||||
|
||||
return {
|
||||
"id": row[0],
|
||||
"source": row[1],
|
||||
"title": row[2],
|
||||
"summary": row[3],
|
||||
"url": row[4],
|
||||
"tags": tags,
|
||||
"published_at": row[6],
|
||||
"collected_at": row[7],
|
||||
"content_text": row[8],
|
||||
"content_html": row[9],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/items")
|
||||
async def list_items(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
source: str = Query(""),
|
||||
tag: str = Query(""),
|
||||
since: str = Query(""),
|
||||
):
|
||||
where = []
|
||||
params = []
|
||||
|
||||
if source:
|
||||
where.append("source = ?")
|
||||
params.append(source)
|
||||
if tag:
|
||||
where.append("tags LIKE ?")
|
||||
params.append(f'%"{tag}"%')
|
||||
if since:
|
||||
where.append("collected_at >= ?")
|
||||
params.append(since)
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(where)}" if where else ""
|
||||
|
||||
async with await _db() as db:
|
||||
count_cursor = await db.execute(
|
||||
f"SELECT COUNT(*) FROM info_items {where_clause}",
|
||||
tuple(params),
|
||||
)
|
||||
total = (await count_cursor.fetchone())[0]
|
||||
|
||||
cursor = await db.execute(
|
||||
f"""
|
||||
SELECT id, source, title, summary, url, tags, published_at, collected_at, content_text, content_html
|
||||
FROM info_items
|
||||
{where_clause}
|
||||
ORDER BY COALESCE(published_at, collected_at) DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
tuple(params + [limit, offset]),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return {"items": [_row_to_item(row) for row in rows], "total": total}
|
||||
|
||||
|
||||
@router.get("/items/{item_id}")
|
||||
async def get_item(item_id: int):
|
||||
async with await _db() as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT id, source, title, summary, url, tags, published_at, collected_at, content_text, content_html
|
||||
FROM info_items
|
||||
WHERE id = ?
|
||||
""",
|
||||
(item_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Item not found")
|
||||
|
||||
return _row_to_item(row)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
async with await _db() as db:
|
||||
cursor = await db.execute("SELECT COUNT(*), MAX(collected_at) FROM info_items")
|
||||
row = await cursor.fetchone()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"item_count": row[0] if row else 0,
|
||||
"last_collect_at": row[1] if row else None,
|
||||
}
|
||||
Reference in New Issue
Block a user