Spaces:
Runtime error
Runtime error
GitHub Actions Bot commited on
Commit Β·
985f3ee
0
Parent(s):
deploy: automated sync from github main
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .gitattributes +5 -0
- .github/workflows/deploy.yml +43 -0
- .gitignore +36 -0
- .opencode/opencode-loop/ses_04f15b806ffeVMcZMQ3SvTElIa.json +4 -0
- .opencode/opencode-loop/ses_04f15c7c7ffeptX8ZkC4eI4nwV.json +4 -0
- .opencode/opencode-loop/ses_06a20babdffeQ3bykyB3CydzUC.json +4 -0
- .opencode/opencode-loop/ses_06a20e31effezqv3Ypf6NlGToc.json +4 -0
- .python-version +1 -0
- AGENTS.md +128 -0
- Dockerfile +28 -0
- GOAL.MD +112 -0
- README.md +149 -0
- aruncore_master_blueprint.md +257 -0
- backend/README.md +64 -0
- backend/app/api/v1/config.py +13 -0
- backend/app/api/v1/router.py +9 -0
- backend/app/api/v1/voice.py +20 -0
- backend/app/api/v1/webhook.py +20 -0
- backend/app/core/README.md +52 -0
- backend/app/core/__init__.py +0 -0
- backend/app/core/agent.py +136 -0
- backend/app/core/api.py +207 -0
- backend/app/core/bot.py +134 -0
- backend/app/core/evaluate.py +171 -0
- backend/app/core/ingest.py +279 -0
- backend/app/main.py +15 -0
- backend/app/schemas/chat.py +30 -0
- backend/app/schemas/tenant.py +122 -0
- backend/app/schemas/voice.py +8 -0
- backend/app/schemas/webhook.py +15 -0
- backend/app/services/active_learning_service.py +18 -0
- backend/app/services/agent_runner.py +428 -0
- backend/app/services/auth_service.py +22 -0
- backend/app/services/background.py +49 -0
- backend/app/services/knowledge_service.py +453 -0
- backend/app/services/memory_manager.py +69 -0
- backend/app/services/notification_service.py +508 -0
- backend/app/services/prompt_builder.py +162 -0
- backend/app/services/rag_service.py +48 -0
- backend/app/services/session_store.py +121 -0
- backend/app/services/tenant_service.py +99 -0
- backend/app/services/tool_executor.py +92 -0
- backend/app/services/voice_service.py +39 -0
- data/HOW_TO_UPDATE_DATA.md +51 -0
- data/README.md +37 -0
- data/github/01_manage_patient_task/README.md +87 -0
- data/github/01_manage_patient_task/metadata.json +10 -0
- data/github/Agentic_AI_Projects/README.md +21 -0
- data/github/Agentic_AI_Projects/metadata.json +10 -0
- data/github/ArunCore/README.md +16 -0
.gitattributes
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/deploy.yml
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face Space
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main ]
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
sync-to-huggingface:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- name: Checkout Repository
|
| 13 |
+
uses: actions/checkout@v4
|
| 14 |
+
with:
|
| 15 |
+
fetch-depth: 0
|
| 16 |
+
|
| 17 |
+
- name: Push to Hugging Face Space
|
| 18 |
+
env:
|
| 19 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 20 |
+
run: |
|
| 21 |
+
git config --global user.name "GitHub Actions Bot"
|
| 22 |
+
git config --global user.email "actions@github.com"
|
| 23 |
+
git checkout --orphan hf-deploy-temp
|
| 24 |
+
git rm -rf --cached . 2>/dev/null || true
|
| 25 |
+
git add -A
|
| 26 |
+
git reset -- Images/ db/ *.png *.jpg *.jpeg 2>/dev/null || true
|
| 27 |
+
git commit -m "deploy: automated sync from github main" || true
|
| 28 |
+
git push --force https://neural-arun:${HF_TOKEN}@huggingface.co/spaces/neural-arun/ArunCore hf-deploy-temp:main
|
| 29 |
+
|
| 30 |
+
- name: Restart Hugging Face Space
|
| 31 |
+
env:
|
| 32 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 33 |
+
run: |
|
| 34 |
+
echo "Triggering Space restart to apply new code..."
|
| 35 |
+
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
| 36 |
+
-X POST "https://huggingface.co/api/spaces/neural-arun/ArunCore/restart?factory=true" \
|
| 37 |
+
-H "Authorization: Bearer ${HF_TOKEN}")
|
| 38 |
+
echo "HF Restart API response: $STATUS"
|
| 39 |
+
if [ "$STATUS" = "200" ] || [ "$STATUS" = "204" ]; then
|
| 40 |
+
echo "β
Space restart triggered successfully!"
|
| 41 |
+
else
|
| 42 |
+
echo "β οΈ Restart returned status $STATUS β Space may need a manual restart."
|
| 43 |
+
fi
|
.gitignore
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Keys and Secrets
|
| 2 |
+
.env
|
| 3 |
+
|
| 4 |
+
# Python Environment
|
| 5 |
+
venv/
|
| 6 |
+
.venv/
|
| 7 |
+
env/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
__pycache__/
|
| 10 |
+
.pytest_cache/
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# Evaluation Logs and Debugging
|
| 15 |
+
evaluation_debug/
|
| 16 |
+
temp_error.txt
|
| 17 |
+
error.txt
|
| 18 |
+
data/test_set/evaluation_report.json
|
| 19 |
+
|
| 20 |
+
# IDE Settings
|
| 21 |
+
.vscode/
|
| 22 |
+
.idea/
|
| 23 |
+
|
| 24 |
+
# OS Junk
|
| 25 |
+
.DS_Store
|
| 26 |
+
Thumbs.db
|
| 27 |
+
|
| 28 |
+
.vercel
|
| 29 |
+
|
| 30 |
+
# Database Files
|
| 31 |
+
db/
|
| 32 |
+
|
| 33 |
+
# Image Artifacts
|
| 34 |
+
*.png
|
| 35 |
+
*.jpg
|
| 36 |
+
*.jpeg
|
.opencode/opencode-loop/ses_04f15b806ffeVMcZMQ3SvTElIa.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": 4,
|
| 3 |
+
"jobs": []
|
| 4 |
+
}
|
.opencode/opencode-loop/ses_04f15c7c7ffeptX8ZkC4eI4nwV.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": 4,
|
| 3 |
+
"jobs": []
|
| 4 |
+
}
|
.opencode/opencode-loop/ses_06a20babdffeQ3bykyB3CydzUC.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": 4,
|
| 3 |
+
"jobs": []
|
| 4 |
+
}
|
.opencode/opencode-loop/ses_06a20e31effezqv3Ypf6NlGToc.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": 4,
|
| 3 |
+
"jobs": []
|
| 4 |
+
}
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.11
|
AGENTS.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# π€ AGENTS.md β Master Protocol & Execution Rules for AI Coding Agents
|
| 2 |
+
|
| 3 |
+
> **IMPORTANT**: Any coding agent or LLM-assisted development tool (Antigravity, Claude, Cursor, Copilot, etc.) working on this repository **MUST** read this file and follow every rule strictly.
|
| 4 |
+
> **ARCHITECTURE VERSION**: βοΈ **v1.0 FROZEN** (Official Release Specification)
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## π― Project Mission
|
| 9 |
+
ArunCore is a domain-generic, multi-tenant enterprise AI platform.
|
| 10 |
+
Your primary objective is **NOT** to maximize code generation.
|
| 11 |
+
Your primary objective is to **preserve architectural integrity by default while implementing the smallest correct change**.
|
| 12 |
+
If a requested feature requires architectural changes, explain the tradeoffs and ask for user confirmation before proceeding.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## π Priority Order (Highest β Lowest)
|
| 17 |
+
When resolving conflicts, follow this exact hierarchy:
|
| 18 |
+
1. π€ **User explicit instructions**
|
| 19 |
+
2. π **AGENTS.md** (Master Protocol & Rules)
|
| 20 |
+
3. π **aruncore_master_blueprint.md** (Architecture Specification)
|
| 21 |
+
4. π **understanding_each_folder/** (Technical Folder Guides)
|
| 22 |
+
5. βοΈ **Existing Codebase**
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## π File Reading Priority
|
| 27 |
+
Always inspect files in this exact hierarchy before modifying code:
|
| 28 |
+
1. π Master Architecture Spec: [`aruncore_master_blueprint.md`](file:///home/arun/projects/profile/aruncore_master_blueprint.md)
|
| 29 |
+
2. π Folder Technical Guides: [`understanding_each_folder/README.md`](file:///home/arun/projects/profile/understanding_each_folder/README.md)
|
| 30 |
+
3. π Validation Schemas: `backend/app/schemas/` (`tenant.py`, `chat.py`)
|
| 31 |
+
4. βοΈ Source Code: `backend/app/` or `frontend/`
|
| 32 |
+
5. π§ͺ Test Suite: `tests/`
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## ποΈ The 11 Golden Architecture Principles (NON-NEGOTIABLE)
|
| 37 |
+
|
| 38 |
+
1. π₯ **Preserve Current Frontend 100%**: Absolutely DO NOT replace or alter the existing Next.js frontend UI (`frontend/app/page.tsx`, `frontend/components/ChatPanel.tsx`, `ProjectsView.tsx`, `ManifestoView.tsx`, `Header.tsx`). The existing luxury UI, design tokens, glowing accent borders, light/dark themes, hero assistant card, and tab views stay 100% intact!
|
| 39 |
+
2. π₯ **Zero Functionality Loss (Keep & Enhance)**: DO NOT remove a single working feature from the current app (Hybrid RAG, Telegram active learning alerts, 3-way live human chat presence, TTS neural voice, admin mode, session history). The goal is to keep 100% of existing functionality and upgrade it to production grade!
|
| 40 |
+
3. π₯ **Production-Grade Hybrid RAG Engine**: Maintain and enhance the hybrid RAG architecture (dense ChromaDB vector search + sparse BM25 keyword search + Cohere/LLM reranking) for ultra-fast, high-precision knowledge retrieval per tenant.
|
| 41 |
+
4. π
**Ultra-Simple Demo System**: Demo mode operates by passing a URL query parameter (`?tutor=ed_donner` or `?client=ed_donner`). The frontend fetches metadata from `/api/v1/config?tutor=ed_donner` (or `/config?tutor=ed_donner`), which dynamically populates the existing hero card title, role subtitle, avatar, welcome text, suggested questions, and AI system prompt. Zero complex slot engines required!
|
| 42 |
+
5. π
**Explicit `tenants/` Directory**: All raw PDFs, markdown files, avatars, logos, and vector databases live in `./tenants/` outside the Git code repo. Keep code repos lightweight (~10MB).
|
| 43 |
+
6. π
**Split Targeted Config**: Tenant configs in `tenants/<id>/config/` are split into 6 targeted JSON files (`brand.json`, `agent.json`, `chat.json`, `voice.json`, `seo.json`, `social.json`). Easy debugging, zero merge conflicts, Pydantic validated.
|
| 44 |
+
7. π
**ZERO Backend Client `if` Statements**: Absolutely NO `if client == "ed":` or `if tenant_id == "hitesh":` statements anywhere in Python code! All tenant logic, prompts, tools, and branding MUST be resolved dynamically by `TenantService`.
|
| 45 |
+
8. π
**Config-Driven Tool Registry**: Client tools are controlled via the `enabled_tools` array in `agent.json` (`["search_courses", "book_calendar", "faq_lookup"]`). `ToolExecutor` dynamically binds enabled tools into the LLM execution loopβzero Python code edits required!
|
| 46 |
+
9. π
**Separation of Tenant Assets**: Static brand images (`tenants/<id>/assets/avatars/`, `tenants/<id>/assets/logos/`) are kept separate from code logic.
|
| 47 |
+
10. π
**Decoupled Single-Responsibility Services**: Maintain small, focused backend services (`PromptBuilder`, `MemoryManager`, `ToolExecutor`, `AgentRunner`, `RAGService`, `NotificationService`, `ActiveLearningService`). Do NOT create monster monolithic service files.
|
| 48 |
+
11. π
**Interface Abstractions**: Use abstract base classes for adapters (`VectorStore`, `StateStore`, `NotificationProvider`). Never hardcode direct dependencies in business logic.
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## βοΈ Required Execution Workflow
|
| 53 |
+
|
| 54 |
+
1. **Understand**: Read the task and relevant architecture docs.
|
| 55 |
+
2. **Locate**: Identify the exact affected files.
|
| 56 |
+
3. **Plan**: If the change affects >5 files, public APIs, schemas, or architecture -> create a plan first. Do NOT write code immediately.
|
| 57 |
+
4. **Minimal Change**: Apply the smallest possible correct change. Prefer modifying existing code over spawning new modules.
|
| 58 |
+
5. **Verify**: Run automated tests (`python3 -m unittest discover -s tests`).
|
| 59 |
+
6. **Update Docs**: Immediately update both `aruncore_master_blueprint.md` AND `understanding_each_folder/` guides.
|
| 60 |
+
7. **Self-Review**: Review your diff against the pre-completion checklist.
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
## β Ask Before Proceeding
|
| 65 |
+
|
| 66 |
+
Always ask for user confirmation before:
|
| 67 |
+
- Deleting files or modules.
|
| 68 |
+
- Renaming large directories.
|
| 69 |
+
- Changing public API contracts or endpoints.
|
| 70 |
+
- Modifying validation schemas or database structures.
|
| 71 |
+
- Changing authentication, security, or permissions logic.
|
| 72 |
+
- Modifying package dependencies or build tooling.
|
| 73 |
+
|
| 74 |
+
---
|
| 75 |
+
|
| 76 |
+
## π When NOT to Code (Escalation Triggers)
|
| 77 |
+
|
| 78 |
+
Stop coding immediately, explain the conflict, and ask for clarification when:
|
| 79 |
+
- Architecture or requirements are unclear.
|
| 80 |
+
- API contracts or schemas are missing.
|
| 81 |
+
- The request contradicts any Golden Principle or blueprint specification.
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## β "Never Do These" (Strict Ban List)
|
| 86 |
+
|
| 87 |
+
Never:
|
| 88 |
+
- Alter or replace the existing Next.js frontend UI layout, headers, tabs, or design system.
|
| 89 |
+
- Remove or degrade existing working features (RAG, Telegram alerts, TTS, active learning loop, admin mode).
|
| 90 |
+
- Invent APIs, schemas, environment variables, endpoints, or tenant configs without authoritative source definitions.
|
| 91 |
+
- Write hardcoded `if client == "..."` logic anywhere in Python code.
|
| 92 |
+
- Wrap failing logic in silent `try/except: pass` or return dummy empty fallbacks.
|
| 93 |
+
- Skip verification commands or leave TODO implementations.
|
| 94 |
+
- Rename large directories, reformat unrelated files, or modify package lockfiles unless explicitly requested.
|
| 95 |
+
- Hardcode secrets, log tokens/API keys, or disable validation/authentication.
|
| 96 |
+
- Introduce N+1 database queries, blocking I/O on main loops, or redundant vector searches.
|
| 97 |
+
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
## π Minimal Change Principle
|
| 101 |
+
- Prefer modifying existing code over creating new abstractions.
|
| 102 |
+
- Only create new modules or files when strictly necessary. Avoid spawning unnecessary files.
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## β
Definition of "DONE"
|
| 107 |
+
|
| 108 |
+
A task is complete ONLY if:
|
| 109 |
+
- [ ] Code compiles and builds cleanly without warnings.
|
| 110 |
+
- [ ] Existing Next.js frontend UI is 100% preserved and functional.
|
| 111 |
+
- [ ] 100% of existing capabilities (Hybrid RAG, Telegram active learning, TTS, 3-way chat) remain fully intact.
|
| 112 |
+
- [ ] Verification tests pass (`python3 -m unittest discover -s tests`).
|
| 113 |
+
- [ ] Both `aruncore_master_blueprint.md` AND `understanding_each_folder/` guides are fully updated.
|
| 114 |
+
- [ ] No Golden Architecture Principles are violated.
|
| 115 |
+
- [ ] No dead code, commented-out blocks, or unused imports remain.
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## π Pre-Completion Review Checklist
|
| 120 |
+
|
| 121 |
+
Before marking any task complete, verify:
|
| 122 |
+
- [ ] **Frontend Preserved**: Existing UI layout, tabs (`Chat`, `Projects`, `Manifesto`), design tokens, and components are 100% intact.
|
| 123 |
+
- [ ] **Features Retained**: Zero feature removals (Hybrid RAG, Telegram alerts, TTS, active learning loop preserved).
|
| 124 |
+
- [ ] **Architecture preserved**: Zero `if client == ...` checks, clean backend decoupling.
|
| 125 |
+
- [ ] **Minimal diff**: No unrelated files edited or reformatted.
|
| 126 |
+
- [ ] **Type safety**: Pydantic validation used in backend; explicit TypeScript interfaces used in frontend.
|
| 127 |
+
- [ ] **Documentation synced**: Blueprint and folder guides match the diff.
|
| 128 |
+
- [ ] **Tests verified**: Test suite passed with zero errors.
|
Dockerfile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Set working directory
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install system dependencies & Node.js for frontend static export
|
| 7 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
+
build-essential \
|
| 9 |
+
curl \
|
| 10 |
+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 11 |
+
&& apt-get install -y nodejs \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# Copy requirements and install python dependencies
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Copy the entire project
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Build frontend static export
|
| 22 |
+
RUN cd frontend && npm install && npm run build
|
| 23 |
+
|
| 24 |
+
# Expose standard container port
|
| 25 |
+
EXPOSE 8000
|
| 26 |
+
|
| 27 |
+
# Run FastAPI server (which serves the mounted frontend and API endpoints)
|
| 28 |
+
CMD ["sh", "-c", "if [ \"$RUN_TELEGRAM_PUBLIC_BOT\" = \"true\" ]; then python -m backend.app.core.bot & fi; uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
|
GOAL.MD
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# π― GOAL.MD β ArunCore Production Upgrade Roadmap & Master Plan
|
| 2 |
+
|
| 3 |
+
> **STATUS**: π **MASTER EXECUTION SPECIFICATION**
|
| 4 |
+
> **PROJECT**: **ArunCore Multi-Tenant Enterprise AI Platform**
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## π Executive Objective & Mission Statement
|
| 9 |
+
|
| 10 |
+
The primary goal of this project is to upgrade **ArunCore** into a production-grade, domain-generic, multi-tenant enterprise AI platform while adhering strictly to three non-negotiable directives:
|
| 11 |
+
|
| 12 |
+
1. π₯ **100% Frontend Preservation**: The existing Next.js frontend UI layout, components, header, tabs (`ChatPanel`, `ProjectsView`, `ManifestoView`), glowing accent borders, light/dark themes, hero assistant card, and design tokens stay **100% INTACT**. No layout rewrites or UI structural replacements will take place.
|
| 13 |
+
2. π₯ **Zero Functionality Loss (Keep & Enhance Everything)**: Absolutely **NO** working features will be removed from the current app. All existing capabilitiesβincluding the Hybrid RAG engine, Telegram active learning alerts, 3-way live human chat presence notice, TTS neural voice endpoints, admin mode, and session historyβwill be preserved 100% and upgraded for production-grade scaling!
|
| 14 |
+
3. π₯ **Production-Grade Hybrid RAG Engine**: The RAG retrieval pipeline (dense ChromaDB vector search + sparse BM25 keyword search + Cohere/LLM reranking) will be enhanced to deliver hyper-fast, accurate, context-aware answers per tenant.
|
| 15 |
+
4. π
**Ultra-Simple Demo Mode (`?tutor=ed_donner` / `?client=ed_donner`)**: Demo mode operates by passing a URL query parameter (`?tutor=ed_donner`). The frontend fetches metadata from `/api/v1/config?tutor=ed_donner` (or `/config?tutor=ed_donner`), which dynamically populates the existing hero card title, role subtitle, avatar, welcome text, suggested questions, and AI system prompt.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## ποΈ The 11 Golden Architecture Principles (NON-NEGOTIABLE)
|
| 20 |
+
|
| 21 |
+
1. π₯ **Preserve Current Frontend 100%**: Absolutely DO NOT replace or alter the existing Next.js frontend UI (`frontend/app/page.tsx`, `frontend/components/ChatPanel.tsx`, `ProjectsView.tsx`, `ManifestoView.tsx`, `Header.tsx`). The existing luxury UI, design tokens, glowing accent borders, light/dark themes, hero assistant card, and tab views stay 100% intact!
|
| 22 |
+
2. π₯ **Zero Functionality Loss (Keep & Enhance)**: DO NOT remove a single working feature from the current app (Hybrid RAG, Telegram active learning alerts, 3-way live human chat presence, TTS neural voice, admin mode, session history). The goal is to keep 100% of existing functionality and upgrade it to production grade!
|
| 23 |
+
3. π₯ **Production-Grade Hybrid RAG Engine**: Maintain and enhance the hybrid RAG architecture (dense ChromaDB vector search + sparse BM25 keyword search + Cohere/LLM reranking) for ultra-fast, high-precision knowledge retrieval per tenant.
|
| 24 |
+
4. π
**Ultra-Simple Demo System**: Demo mode operates by passing a URL query parameter (`?tutor=ed_donner` or `?client=ed_donner`). The frontend fetches metadata from `/api/v1/config?tutor=ed_donner` (or `/config?tutor=ed_donner`), which dynamically populates the existing hero card title, role subtitle, avatar, welcome text, suggested questions, and AI system prompt. Zero complex slot engines required!
|
| 25 |
+
5. π
**Explicit `tenants/` Directory**: All raw PDFs, markdown files, avatars, logos, and vector databases live in `./tenants/` outside the Git code repo. Keep code repos lightweight (~10MB).
|
| 26 |
+
6. π
**Split Targeted Config**: Tenant configs in `tenants/<id>/config/` are split into 6 targeted JSON files (`brand.json`, `agent.json`, `chat.json`, `voice.json`, `seo.json`, `social.json`). Easy debugging, zero merge conflicts, Pydantic validated.
|
| 27 |
+
7. π
**ZERO Backend Client `if` Statements**: Absolutely NO `if client == "ed":` or `if tenant_id == "hitesh":` statements anywhere in Python code! All tenant logic, prompts, tools, and branding MUST be resolved dynamically by `TenantService`.
|
| 28 |
+
8. π
**Config-Driven Tool Registry**: Client tools are controlled via the `enabled_tools` array in `agent.json` (`["search_courses", "book_calendar", "faq_lookup"]`). `ToolExecutor` dynamically binds enabled tools into the LLM execution loopβzero Python code edits required!
|
| 29 |
+
9. π
**Separation of Tenant Assets**: Static brand images (`tenants/<id>/assets/avatars/`, `tenants/<id>/assets/logos/`) are kept separate from code logic.
|
| 30 |
+
10. π
**Decoupled Single-Responsibility Services**: Maintain small, focused backend services (`PromptBuilder`, `MemoryManager`, `ToolExecutor`, `AgentRunner`, `RAGService`, `NotificationService`, `ActiveLearningService`). Do NOT create monster monolithic service files.
|
| 31 |
+
11. π
**Interface Abstractions**: Use abstract base classes for adapters (`VectorStore`, `StateStore`, `NotificationProvider`). Never hardcode direct dependencies in business logic.
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## π οΈ Step-by-Step Execution Plan (Detailed Roadmap)
|
| 36 |
+
|
| 37 |
+
```
|
| 38 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
β Detailed Step-by-Step Process β
|
| 40 |
+
ββββββββββββββββ¬βββββββββββββββ¬βββββββββββββββ¬βββββββββββββββ¬ββββββββββββββββββ€
|
| 41 |
+
β STEP 1 β STEP 2 β STEP 3 β STEP 4 β STEP 5 β
|
| 42 |
+
β tenants/ β Pydantic β Decoupled β Versioned β Testing & β
|
| 43 |
+
β Directory & β Validation β Services β API Routers β Verification β
|
| 44 |
+
β Split Configsβ Schemas β Architecture β (/api/v1/) β β
|
| 45 |
+
ββββββββββββββββ΄βββββββββββββββ΄βββββββββββββββ΄βββββββββββββββ΄ββββββββββββββββββ
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
### π Step 1: Explicit `tenants/` Directory & Split Targeted Configs
|
| 49 |
+
- Create `./tenants/` root directory structure outside Git.
|
| 50 |
+
- Build `tenants/tenant_starter/` and `tenants/ed_donner/` with 6 split JSON configs:
|
| 51 |
+
- `brand.json`: Hero title, role subtitle, avatar path, accent color.
|
| 52 |
+
- `agent.json`: System prompt, guardrails, `enabled_tools` array.
|
| 53 |
+
- `chat.json`: Welcome message, suggested question chips.
|
| 54 |
+
- `voice.json`: TTS alloy voice specs.
|
| 55 |
+
- `seo.json`: Meta title & description.
|
| 56 |
+
- `social.json`: LinkedIn, X, Udemy links.
|
| 57 |
+
|
| 58 |
+
### π Step 2: Pydantic Validation Schemas (`backend/app/schemas/`)
|
| 59 |
+
- Create Pydantic models:
|
| 60 |
+
- `tenant.py`: Validates all 6 tenant JSON configs on load.
|
| 61 |
+
- `chat.py`: Validates `ChatRequest`, streaming NDJSON chunks, session history.
|
| 62 |
+
- `webhook.py`: Validates Telegram/WhatsApp active learning replies.
|
| 63 |
+
- `voice.py`: Validates `TTSRequest` and STT response schemas.
|
| 64 |
+
|
| 65 |
+
### π Step 3: Decoupled Single-Responsibility Services (`backend/app/services/`)
|
| 66 |
+
- Build decoupled Python services preserving 100% of existing functionality:
|
| 67 |
+
- `tenant_service.py`: Dynamic loader for `tenants/<id>/config/` with **ZERO backend `if client == ...` statements**.
|
| 68 |
+
- `prompt_builder.py`: Synthesizes dynamic system prompts, guardrails, bio rules, and 3-way live human chat presence notices.
|
| 69 |
+
- `memory_manager.py`: Manages rolling chat session window context.
|
| 70 |
+
- `tool_executor.py`: Dynamic sandbox for tools specified in `agent.json` (`enabled_tools`).
|
| 71 |
+
- `agent_runner.py`: Handles 100% AI agent control and token-by-token streaming response generation.
|
| 72 |
+
- `rag_service.py`: Hybrid ChromaDB vector search + BM25 keyword search coordinator with reranking.
|
| 73 |
+
- `notification_service.py`: Async Telegram & WhatsApp alert dispatcher.
|
| 74 |
+
- `active_learning_service.py`: Real-time Q&A vector re-ingestion service.
|
| 75 |
+
|
| 76 |
+
### π Step 4: Interface Adapters (`backend/app/db/`)
|
| 77 |
+
- Implement abstract base classes and adapters:
|
| 78 |
+
- `interfaces.py`: Abstract classes (`VectorStore`, `StateStore`, `NotificationProvider`).
|
| 79 |
+
- `vectorstore.py`: ChromaDB + BM25 hybrid implementation.
|
| 80 |
+
- `state_store.py`: Thread-safe session memory store implementation.
|
| 81 |
+
|
| 82 |
+
### π Step 5: Versioned API Routers (`backend/app/api/v1/`)
|
| 83 |
+
- Implement FastAPI versioned routers:
|
| 84 |
+
- `router.py`: Master v1 aggregator.
|
| 85 |
+
- `chat.py`: Streaming `/api/v1/chat`, `/chat/history`, `/chat/human-messages`.
|
| 86 |
+
- `config.py`: Dynamic metadata endpoint `/api/v1/config` (powers `?tutor=ed_donner`).
|
| 87 |
+
- `webhook.py`: Telegram & WhatsApp active learning reply webhooks.
|
| 88 |
+
- `voice.py`: `/api/v1/voice/tts` neural audio generation.
|
| 89 |
+
|
| 90 |
+
### π Step 6: Frontend Metadata Wiring & Demo Mode Integration
|
| 91 |
+
- Connect Next.js frontend (`frontend/app/page.tsx`) to fetch metadata from `/api/v1/config?tutor=ed_donner`.
|
| 92 |
+
- Dynamically update hero title, role subtitle, avatar, welcome text, suggested questions, and AI prompt while **preserving 100% of the existing luxury UI layout, tabs, and components**.
|
| 93 |
+
|
| 94 |
+
### π Step 7: Automated Test Suite & Verification (`tests/`)
|
| 95 |
+
- Create test suite in `tests/`:
|
| 96 |
+
- `test_schemas.py`: Verifies Pydantic schema validation.
|
| 97 |
+
- `test_tenant_service.py`: Verifies dynamic tenant loading.
|
| 98 |
+
- `test_chat_api.py`: Verifies streaming responses and endpoint contracts.
|
| 99 |
+
- Gather concrete empirical runtime verification.
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## π― Verification Criteria for Success (Definition of "DONE")
|
| 104 |
+
|
| 105 |
+
- [ ] Existing Next.js frontend UI layout, components, tabs, and design system are 100% preserved.
|
| 106 |
+
- [ ] 100% of current functionality (Hybrid RAG, Telegram active learning alerts, TTS, 3-way live human chat, admin mode) remains fully functional.
|
| 107 |
+
- [ ] Hybrid RAG retrieval (ChromaDB + BM25 + Cohere reranking) is enhanced and production-ready per tenant.
|
| 108 |
+
- [ ] Demo mode (`?tutor=ed_donner`) dynamically populates hero card metadata and AI system prompt.
|
| 109 |
+
- [ ] Backend contains ZERO `if client == "..."` checks anywhere in Python code.
|
| 110 |
+
- [ ] `tenants/` storage structure holds isolated client data outside Git code repo.
|
| 111 |
+
- [ ] All automated tests pass (`python3 -m unittest discover -s tests`).
|
| 112 |
+
- [ ] `aruncore_master_blueprint.md` and `AGENTS.md` match the diff.
|
README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ArunCore Enterprise AI Platform & Digital Twin System
|
| 3 |
+
emoji: π§
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# π§ ArunCore β Domain-Generic Multi-Tenant Enterprise AI Platform & Digital Twin
|
| 12 |
+
|
| 13 |
+
> **Architecture Spec Version:** βοΈ **v1.0 FROZEN**
|
| 14 |
+
> **Engine:** `gpt-4.1-nano` | **RAG:** ChromaDB (Dense) + BM25 (Sparse) + Cohere V3 (Reranker)
|
| 15 |
+
> **Frontend:** Next.js 16 (App Router) Luxury Dark/Light UI
|
| 16 |
+
> **Backend:** FastAPI Decoupled Microservices
|
| 17 |
+
> **Evaluation:** Automated 30-Question Multi-Turn ReAct Stress-Test Suite
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## π― Platform Overview
|
| 22 |
+
|
| 23 |
+
**ArunCore** is a domain-generic, multi-tenant enterprise AI platform and stateful personal digital twin built for **Arun Yadav** (AI Systems Architect specializing in Healthcare & Education).
|
| 24 |
+
|
| 25 |
+
The platform serves dual core functions:
|
| 26 |
+
1. **Personal AI Digital Twin**: A 24/7 interactive representative of Arun Yadav, providing deep technical answers about his AI architectures, healthcare/education projects, GitHub repositories, and LinkedIn publications, backed by instant lead capture and dual Telegram alert capabilities.
|
| 27 |
+
2. **Multi-Tenant Enterprise JSON Engine (`?tutor=<tutor_id>`)**: A zero-code multi-tenant advisor system capable of instantly spinning up bespoke 24/7 AI Course Advisors and Sales Representatives for any client or instructor (e.g. Ed Donner) via a single 238-key enterprise JSON schema dictionary.
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## ποΈ Core Architecture Principles (Golden Rules)
|
| 32 |
+
|
| 33 |
+
1. **Preserve Frontend 100%**: Next.js luxury UI (`app/page.tsx`, `ChatPanel.tsx`, `ProjectsView.tsx`, `ManifestoView.tsx`, `Header.tsx`) with glowing accent borders, light/dark themes, hero card, and tab views stays 100% intact.
|
| 34 |
+
2. **Zero Functionality Loss**: Retains Hybrid RAG, active Telegram alerts, 3-way live human chat takeover, TTS neural voice studio, and evaluation harness.
|
| 35 |
+
3. **Production-Grade Hybrid RAG Engine**: ChromaDB dense vector search + BM25 sparse keyword search + Cohere English V3 reranking for high-precision, zero-hallucination retrieval.
|
| 36 |
+
4. **Dynamic JSON Config Engine**: Zero hardcoded `if client == "ed"` logic in Python or TypeScript. 100% of tenant branding, prompts, tools, headers, and catalogs resolve dynamically from JSON dictionaries (`demos/<tutor_id>_enterprise_dictionary.json`).
|
| 37 |
+
5. **Config-Driven Tool Registry**: Client tools are controlled via `enabled_tools` array in config (`["search_arun_knowledge", "get_github_live_data", "notify_arun"]`).
|
| 38 |
+
6. **Decoupled Backend Services**: Monoliths are banned; small single-responsibility services (`PromptBuilder`, `MemoryManager`, `ToolExecutor`, `RAGService`, `TenantService`, `VoiceService`, `ActiveLearningService`).
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## β‘ Key Technical Capabilities
|
| 43 |
+
|
| 44 |
+
### 1. π€ 7-Iteration Recursive Agentic Loop (`gpt-4.1-nano`)
|
| 45 |
+
- Powered by `gpt-4.1-nano` with up to **7 recursive tool execution turns** (`search_arun_knowledge`, `get_github_live_data`, `notify_arun`) per turn.
|
| 46 |
+
- Executes multi-step technical comparisons, live GitHub commit fetches, and knowledge base retrievals before generating answers.
|
| 47 |
+
|
| 48 |
+
### 2. π 2-Step GitHub & Knowledge Base Retrieval Pipeline
|
| 49 |
+
- Automatically fetches the 3 most recently updated repositories and live commit logs from GitHub API (`get_github_live_data`).
|
| 50 |
+
- Automatically queries local README architectures (`search_arun_knowledge`) for each returned repository name to provide deep technical details and direct clickable URLs.
|
| 51 |
+
|
| 52 |
+
### 3. π Markdown Table & Direct Project Link Mandate
|
| 53 |
+
- Presents all multi-system comparisons (e.g. MedCoach vs NEET Bot vs Legal RAG) in clean **Markdown Tables**.
|
| 54 |
+
- Embeds direct, clickable GitHub URLs (`https://github.com/neural-arun/<repo>`) directly inside table headers and project descriptions.
|
| 55 |
+
|
| 56 |
+
### 4. πΌ LinkedIn Insights & Social Engagement CTA
|
| 57 |
+
- Integrates scraped public LinkedIn posts (`data/linkedin/posts.md`) covering AI workforce trends (2026β2036), NEET CBT practice ecosystem, FastAPI Todo API, and BPSC Rank 5 updates.
|
| 58 |
+
- Appends direct clickable LinkedIn post URLs and natural social engagement calls-to-action inviting visitors to like, comment, or share their thoughts.
|
| 59 |
+
|
| 60 |
+
### 5. π¨ Dual-Channel Telegram Alerts & 3-Way Live Human Chat Takeover
|
| 61 |
+
- **Active Learning Logger**: Silently logs all chats to `@ai_twin_alert_bot`.
|
| 62 |
+
- **Urgent Lead Alert Bot**: Instantly alerts Arun's phone for hiring leads, unknown questions, or urgent inquiries with a **1-Click Magic Join Link** for real-time 3-way human chat takeover.
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## π Repository Directory Map
|
| 67 |
+
|
| 68 |
+
```text
|
| 69 |
+
profile/
|
| 70 |
+
βββ backend/ # Python FastAPI Backend Architecture
|
| 71 |
+
β βββ app/
|
| 72 |
+
β βββ api/v1/ # API Endpoints (/config, /chat, /voice, /webhook)
|
| 73 |
+
β βββ core/ # Core Orchestration (agent.py, api.py, bot.py, ingest.py)
|
| 74 |
+
β βββ schemas/ # Pydantic Schemas (tenant.py, chat.py, voice.py)
|
| 75 |
+
β βββ services/ # Decoupled Business Logic Services
|
| 76 |
+
β βοΏ½οΏ½β main.py # FastAPI Application Entrypoint
|
| 77 |
+
βββ frontend/ # Next.js 16 (App Router) Luxury UI
|
| 78 |
+
β βββ app/ # Page Routes & Global Styles
|
| 79 |
+
β βββ components/ # UI Components (ChatPanel, Header, ProjectsView, etc.)
|
| 80 |
+
β βββ public/ # Static Avatars & Logos
|
| 81 |
+
βββ data/ # Data Stores & Knowledge Assets (Excluded from Git code bloat)
|
| 82 |
+
β βββ github/ # Readme files for all 21 public GitHub repos
|
| 83 |
+
β βββ linkedin/ # Scraped LinkedIn posts (posts.md)
|
| 84 |
+
β βββ raw/ # Personal background & unknown questions DB
|
| 85 |
+
β βββ static/ # Public profile, rules of engagement, voice persona
|
| 86 |
+
βββ db/ # ChromaDB Vector Database & Ingestion State
|
| 87 |
+
βββ demos/ # Enterprise 238-Key Monolithic JSON Schemas
|
| 88 |
+
β βββ ed_donner_enterprise_dictionary.json
|
| 89 |
+
β βββ general.json # Master 238-key JSON Template
|
| 90 |
+
β βββ master_enterprise_dictionary.json
|
| 91 |
+
βββ scripts/ # Automated Maintenance & Evaluation Suite
|
| 92 |
+
β βββ evaluate.py # 30-Question Multi-Turn ReAct Test Harness
|
| 93 |
+
β βββ evaluation_questions.md # 30 Test Questions List
|
| 94 |
+
β βββ evaluation_results.md # Generated Output & Execution Traces
|
| 95 |
+
β βββ ingest.py # ChromaDB Re-Ingestion Script
|
| 96 |
+
β βββ sync_github.py # GitHub API Auto-Sync Script
|
| 97 |
+
β βββ sync_linkedin.py # LinkedIn Posts Sync Script
|
| 98 |
+
β βββ sync_all.py # 1-Click Master Data Sync Runner
|
| 99 |
+
βββ tenants/ # Multi-Tenant 6-File JSON Configurations
|
| 100 |
+
βββ tests/ # Automated Unit & Integration Test Suite
|
| 101 |
+
βββ AGENTS.md # Master Protocol & Execution Rules for AI Coding Agents
|
| 102 |
+
βββ aruncore_master_blueprint.md # Complete Architecture Specification Doc
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## π Quick Start & Local Execution
|
| 108 |
+
|
| 109 |
+
### 1. Environment Setup
|
| 110 |
+
Ensure `.env` exists in root with valid API keys:
|
| 111 |
+
```env
|
| 112 |
+
OPENAI_API_KEY=sk-proj-...
|
| 113 |
+
COHERE_API_KEY=...
|
| 114 |
+
GITHUB_USERNAME=neural-arun
|
| 115 |
+
GITHUB_TOKEN=github_pat_...
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### 2. Run Backend Server (FastAPI on Port 8000)
|
| 119 |
+
```bash
|
| 120 |
+
source .venv/bin/activate
|
| 121 |
+
python3 -m uvicorn backend.app.main:app --reload --port 8000
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
### 3. Run Frontend Dev Server (Next.js on Port 3000)
|
| 125 |
+
```bash
|
| 126 |
+
cd frontend
|
| 127 |
+
npm run dev
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
### 4. Run Automated Evaluation Test Suite
|
| 131 |
+
```bash
|
| 132 |
+
python3 scripts/evaluate.py
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
## π§ͺ Verification & Automated Testing
|
| 138 |
+
|
| 139 |
+
Execute the complete backend unit test suite:
|
| 140 |
+
```bash
|
| 141 |
+
python3 -m unittest discover -s tests
|
| 142 |
+
```
|
| 143 |
+
*Expected Output:* `Ran 9 tests in 0.028s OK`
|
| 144 |
+
|
| 145 |
+
---
|
| 146 |
+
|
| 147 |
+
## π License & Copyright
|
| 148 |
+
|
| 149 |
+
Β© 2026 **Arun Yadav** ([neural.arun.dev@gmail.com](mailto:neural.arun.dev@gmail.com)). All rights reserved.
|
aruncore_master_blueprint.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ποΈ ArunCore β Master Enterprise AI System Blueprint & Architecture Specification (v1.0 Frozen)
|
| 2 |
+
|
| 3 |
+
> **STATUS**: βοΈ **ARCHITECTURE v1.0 FROZEN** (Official Release Specification)
|
| 4 |
+
|
| 5 |
+
## π Executive Overview & System Philosophy
|
| 6 |
+
|
| 7 |
+
**ArunCore** is a production-grade, stateful, agentic portfolio and multi-tenant enterprise AI engine platform created by **Arun Yadav** (AI Systems Architect specializing in Healthcare & Education).
|
| 8 |
+
|
| 9 |
+
**ArunCore** operates on a **Domain-Generic Engine Architecture** with **100% Preservation of Arun's Existing Next.js Frontend UI** and **Zero Loss of Existing Capabilities**. The **AI Agent stays in control 100% of the time**. Instead of complex live web page human takeovers, **ArunCore** relies on a high-precision **Hybrid RAG Engine** and an **Active Learning & Direct Messaging Loop (Telegram / WhatsApp)**:
|
| 10 |
+
- **Hybrid RAG Retrieval**: Combines dense vector search (ChromaDB), sparse keyword search (BM25), and Cohere/LLM reranking to deliver hyper-accurate, context-aware answers per tenant.
|
| 11 |
+
- **Active Learning Loop**: When a visitor asks an unknown, personal, weird, or low-confidence question, the AI Twin responds politely and alerts the owner on Telegram/WhatsApp with a 1-Click reply link.
|
| 12 |
+
- **Real-Time Vector Ingestion**: The owner replies directly in Telegram/WhatsApp, automatically ingesting the answer into ChromaDB in real time to train the AI Twin instantly for all future visitors!
|
| 13 |
+
- **3-Way Live Chat Presence**: When the owner joins a live session via Telegram, the AI system prompt dynamically injects a 3-way chat notice acknowledging the human instructor's live presence alongside the AI assistant.
|
| 14 |
+
|
| 15 |
+
### The Business & Product Model:
|
| 16 |
+
1. **Ultra-Simple Demo Mode (`?tutor=ed_donner` / `?client=ed_donner`)**: Instant data-driven client demos for 2-minute sales pitch videos to potential buyers. Passing `?tutor=ed_donner` in the URL fetches `/api/v1/config?tutor=ed_donner` (or `/config?tutor=ed_donner`), which dynamically updates the hero card title, role subtitle, avatar, welcome text, suggested questions, and AI system prompt on Arun's existing stunning UI!
|
| 17 |
+
2. **Paid Client Onboarding ($300 β $1,000+ Tier)**: Rapid 5-minute onboarding of custom AI twins for tutors, instructors, doctors, lawyers, or consultants, with zero code duplication, zero backend `if client == ...` statements, zero frontend UI changes, and 100% isolated tenant data in `tenants/`.
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## π― The 11 Senior Systems Architect Principles
|
| 22 |
+
|
| 23 |
+
```
|
| 24 |
+
π₯ 1. Preserve Current Frontend π₯ 2. Zero Functionality Loss π₯ 3. Production Hybrid RAG π
4. Ultra-Simple Demos
|
| 25 |
+
(Keep page.tsx, ChatPanel, (Keep RAG, Telegram alerts, (ChromaDB + BM25 + Cohere (?tutor=ed_donner dynamically
|
| 26 |
+
ProjectsView & Manifesto) TTS, 3-way live chat notice) dense/sparse hybrid engine) updates hero card & prompt)
|
| 27 |
+
|
| 28 |
+
π
5. Explicit tenants/ Storage π
6. Split Targeted Config π
7. Zero Client Ifs π
8. Config-Driven Tools
|
| 29 |
+
(PDFs, docs & configs in (brand, agent, chat, voice, (100% TenantService (enabled_tools array in
|
| 30 |
+
tenants/ outside Git) seo, social sub-configs) dynamic resolution) agent.json controls tools)
|
| 31 |
+
|
| 32 |
+
π
9. Separate Tenant Assets π
10. Decoupled Backend π
11. Direct Active Learning
|
| 33 |
+
(tenants/<id>/assets/ logos (PromptBuilder, ToolExec, (Owner replies via Telegram,
|
| 34 |
+
& avatars isolated) MemoryManager, AgentRunner) auto-ingesting into RAG DB)
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
1. **Preserve Current Frontend 100%**: Absolutely DO NOT replace or alter the existing Next.js frontend UI (`frontend/app/page.tsx`, `frontend/components/ChatPanel.tsx`, `ProjectsView.tsx`, `ManifestoView.tsx`, `Header.tsx`). The existing luxury UI, design tokens, glowing accent borders, light/dark themes, hero assistant card, and tab views stay 100% intact!
|
| 38 |
+
2. **Zero Functionality Loss (Keep & Enhance)**: DO NOT remove a single working feature from the current app (Hybrid RAG, Telegram active learning alerts, 3-way live human chat presence, TTS neural voice, admin mode, session history). The goal is to keep 100% of existing functionality and upgrade it to production grade!
|
| 39 |
+
3. **Production-Grade Hybrid RAG Engine**: Maintain and enhance the hybrid RAG architecture (dense ChromaDB vector search + sparse BM25 keyword search + Cohere/LLM reranking) for ultra-fast, high-precision knowledge retrieval per tenant.
|
| 40 |
+
4. **Ultra-Simple Demo System**: Demo mode operates by passing a URL query parameter (`?tutor=ed_donner` or `?client=ed_donner`). The frontend fetches metadata from `/api/v1/config?tutor=ed_donner` (or `/config?tutor=ed_donner`), which dynamically populates the existing hero card title, role subtitle, avatar, welcome text, suggested questions, and AI system prompt. Zero complex slot engines required!
|
| 41 |
+
5. **Explicit `tenants/` Storage**: All raw PDFs, markdown files, avatars, logos, and vector databases live in `./tenants/` outside the Git code repo. Keeps `git clone` lightweight (~10MB), enables fast deployments, self-documents tenant data isolation, and allows seamless S3/Cloudflare R2 sync.
|
| 42 |
+
6. **Split Targeted Config**: Instead of one monolithic 238-key `config.json`, tenant configuration is divided into targeted JSON files (`brand.json`, `agent.json`, `chat.json`, `voice.json`, `seo.json`, `social.json`). Easy debugging, zero merge conflicts, Pydantic validated.
|
| 43 |
+
7. **ZERO Backend Client Logic**: No `if client == "ed":` statements anywhere in Python code! All tenant logic is resolved dynamically by `TenantService`.
|
| 44 |
+
8. **Config-Driven Tool Registry**: Client tool activation is controlled entirely via `enabled_tools` in `agent.json` (`["search_courses", "book_calendar", "faq_lookup"]`). `ToolExecutor` dynamically registers only enabled tools into the LLM execution loopβzero Python code edits required!
|
| 45 |
+
9. **Separation of Tenant Assets & Engine**: Static brand assets (`tenants/<id>/assets/avatars/`, `tenants/<id>/assets/logos/`) are kept separate from code logic.
|
| 46 |
+
10. **Decoupled Single-Responsibility Backend Services**: `AgentService` is split into clean micro-services: `PromptBuilder`, `MemoryManager`, `ToolExecutor`, `AgentRunner`, `RAGService`, `NotificationService`, `ActiveLearningService`.
|
| 47 |
+
11. **Interface Abstractions**: Abstract base classes for persistence and notification providers (`VectorStore`, `StateStore`, `NotificationProvider`). Swapping ChromaDB to Qdrant, Redis to Memory, or Telegram to WhatsApp takes 1 line of config!
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## ποΈ End-to-End System Architecture
|
| 52 |
+
|
| 53 |
+
```mermaid
|
| 54 |
+
graph TD
|
| 55 |
+
subgraph Client & UI Layer (frontend/ - 100% Preserved UI)
|
| 56 |
+
ArunVisitor["Arun Portfolio Visitor<br/>(www.neuralarun.in)"]
|
| 57 |
+
DemoVisitor["Demo / Client Visitor<br/>(?tutor=ed_donner)"]
|
| 58 |
+
end
|
| 59 |
+
|
| 60 |
+
subgraph API Gateway Layer (backend/app/api/v1/)
|
| 61 |
+
APIRouter["API Router Aggregator (router.py)"]
|
| 62 |
+
ChatAPI["/api/v1/chat"]
|
| 63 |
+
ConfigAPI["/api/v1/config"]
|
| 64 |
+
VoiceAPI["/api/v1/voice"]
|
| 65 |
+
WebhookAPI["/api/v1/webhook (Telegram / WhatsApp)"]
|
| 66 |
+
end
|
| 67 |
+
|
| 68 |
+
subgraph Business Logic Services (backend/app/services/)
|
| 69 |
+
TenantService["Tenant & Config Service"]
|
| 70 |
+
PromptBuilder["Prompt Builder Service"]
|
| 71 |
+
MemoryManager["Rolling Memory Manager (MemoryManager =<br/>RollingMemory alias)"]
|
| 72 |
+
ToolExecutor["Dynamic Config-Driven Tool Executor"]
|
| 73 |
+
AgentRunner["Agent Runner Engine (100% Agent Control)"]
|
| 74 |
+
KnowledgeService["Knowledge Retrieval Service (GitHub +<br/>READMEs + LinkedIn + static + Q&A)"]
|
| 75 |
+
RAGService["Hybrid RAG Coordinator (ChromaDB + BM25)"]
|
| 76 |
+
NotificationService["Telegram / Alert Dispatcher + Background Queue"]
|
| 77 |
+
SessionService["Thread-Safe Session & Memory Store"]
|
| 78 |
+
AuthService["Admin Token Auth (3-Way Live Takeover)"]
|
| 79 |
+
BackgroundQueue["Shared Background Task Queue"]
|
| 80 |
+
ActiveLearningService["Active Learning (owner-answer ingestion)"]
|
| 81 |
+
end
|
| 82 |
+
|
| 83 |
+
subgraph Interface Adapters (backend/app/db/ & core/)
|
| 84 |
+
VectorStore["VectorStore Interface<br/>(ChromaDB + BM25 + Cohere)"]
|
| 85 |
+
StateStore["StateStore Interface<br/>(Redis / Thread-Safe Memory)"]
|
| 86 |
+
NotificationProvider["NotificationProvider Interface<br/>(Telegram / WhatsApp API)"]
|
| 87 |
+
CompositionRoot["core/agent.py (thin composition<br/>root: init_agent + facade re-exports)"]
|
| 88 |
+
HTTPLayer["core/api.py (thin FastAPI wiring only)"]
|
| 89 |
+
end
|
| 90 |
+
|
| 91 |
+
subgraph Tenant Storage Layer (tenants/)
|
| 92 |
+
TenantConfigs["tenants/<id>/config/<br/>(brand, agent, chat, voice, seo, social)"]
|
| 93 |
+
TenantAssets["tenants/<id>/assets/<br/>(avatars, logos, graphics)"]
|
| 94 |
+
TenantKnowledge["tenants/<id>/knowledge/<br/>(markdown, PDFs, raw data, active_learning.json)"]
|
| 95 |
+
end
|
| 96 |
+
|
| 97 |
+
ArunVisitor --> ConfigAPI
|
| 98 |
+
ArunVisitor --> ChatAPI
|
| 99 |
+
DemoVisitor --> ConfigAPI
|
| 100 |
+
DemoVisitor --> ChatAPI
|
| 101 |
+
|
| 102 |
+
APIRouter --> ChatAPI
|
| 103 |
+
APIRouter --> ConfigAPI
|
| 104 |
+
APIRouter --> VoiceAPI
|
| 105 |
+
APIRouter --> WebhookAPI
|
| 106 |
+
|
| 107 |
+
ChatAPI --> AgentRunner
|
| 108 |
+
ConfigAPI --> TenantService
|
| 109 |
+
WebhookAPI --> ActiveLearningService
|
| 110 |
+
|
| 111 |
+
AgentRunner --> PromptBuilder
|
| 112 |
+
AgentRunner --> MemoryManager
|
| 113 |
+
AgentRunner --> ToolExecutor
|
| 114 |
+
AgentRunner --> KnowledgeService
|
| 115 |
+
AgentRunner --> RAGService
|
| 116 |
+
AgentRunner --> NotificationService
|
| 117 |
+
AgentRunner --> SessionService
|
| 118 |
+
AgentRunner --> CompositionRoot
|
| 119 |
+
|
| 120 |
+
ToolExecutor --> KnowledgeService
|
| 121 |
+
ToolExecutor --> NotificationService
|
| 122 |
+
|
| 123 |
+
NotificationService --> BackgroundQueue
|
| 124 |
+
NotificationService --> AuthService
|
| 125 |
+
HTTPLayer --> AgentRunner
|
| 126 |
+
HTTPLayer --> SessionService
|
| 127 |
+
HTTPLayer --> AuthService
|
| 128 |
+
HTTPLayer --> NotificationService
|
| 129 |
+
|
| 130 |
+
TenantService --> TenantConfigs
|
| 131 |
+
ToolExecutor --> TenantConfigs
|
| 132 |
+
RAGService --> KnowledgeService
|
| 133 |
+
KnowledgeService --> VectorStore
|
| 134 |
+
VectorStore --> TenantKnowledge
|
| 135 |
+
ActiveLearningService --> VectorStore
|
| 136 |
+
ActiveLearningService --> RAGService
|
| 137 |
+
NotificationService --> NotificationProvider
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## π Repository Directory & Tenant Storage Structure
|
| 143 |
+
|
| 144 |
+
```
|
| 145 |
+
aruncore/
|
| 146 |
+
βββ backend/ # βοΈ FASTAPI BACKEND SERVICE
|
| 147 |
+
β βββ app/
|
| 148 |
+
β β βββ api/ # π VERSIONED API ROUTERS (v1)
|
| 149 |
+
β β β βββ v1/
|
| 150 |
+
β β β β βββ router.py # Master v1 API Router Aggregator
|
| 151 |
+
β β β β βββ config.py # /api/v1/config multi-tenant metadata resolution
|
| 152 |
+
β β β β βββ webhook.py # Telegram & WhatsApp reply webhook (Active Learning)
|
| 153 |
+
β β β β βββ voice.py # /api/v1/voice/tts neural TTS audio
|
| 154 |
+
β β β
|
| 155 |
+
β β βββ schemas/ # π PYDANTIC VALIDATION SCHEMAS
|
| 156 |
+
β β β βββ tenant.py # Sub-config schemas (Brand, Agent, Chat, Voice, SEO, Social)
|
| 157 |
+
β β β βββ chat.py # ChatRequest, ChatHistoryResponse, NDJSONStreamChunk schemas
|
| 158 |
+
β β β βββ webhook.py # Telegram/WhatsApp incoming reply webhook schema
|
| 159 |
+
β β β βββ voice.py # TTSRequest schema
|
| 160 |
+
β β β
|
| 161 |
+
β β βββ services/ # π§ SINGLE-RESPONSIBILITY SERVICES (DECOUPLED)
|
| 162 |
+
β β β βββ background.py # Shared background task queue + worker thread
|
| 163 |
+
β β β βββ tenant_service.py # Dynamic tenant + legacy demos config resolver
|
| 164 |
+
β β β βββ prompt_builder.py # System prompt, static context & avatar assembly
|
| 165 |
+
β β β βββ memory_manager.py # RollingMemory summary-compression + MemoryManager alias
|
| 166 |
+
β β β βββ tool_executor.py # Real tool registry (search / github / notify) + placeholders
|
| 167 |
+
β β β βββ agent_runner.py # Streaming + sync agent loop (7-iters, 3-way live notice)
|
| 168 |
+
β β β βββ knowledge_service.py # Reads/writes ALL knowledge data (GitHub, LinkedIn, static, Q&A)
|
| 169 |
+
β β β βββ rag_service.py # Hybrid RAG coordinator + active-learning persistence
|
| 170 |
+
β β β βββ notification_service.py # Telegram send/alert/queue/logging + alert dedup
|
| 171 |
+
β β β βββ auth_service.py # Admin token generation + verification
|
| 172 |
+
β β β βββ session_store.py # Thread-safe session / human-control / memory store
|
| 173 |
+
β β β βββ active_learning_service.py # owner-reply -> RAG ingestion
|
| 174 |
+
β β β βββ voice_service.py # Speech synthesis (TTS)
|
| 175 |
+
β β β
|
| 176 |
+
β β βββ db/ # π INTERFACE ADAPTERS
|
| 177 |
+
β β β βββ interfaces.py # Abstract Base Classes (VectorStore, StateStore, NotificationProvider)
|
| 178 |
+
β β β βββ state_store.py # Thread-Safe In-Memory StateStore Implementation
|
| 179 |
+
β β β
|
| 180 |
+
β β βββ core/ # π§ COMPOSITION ROOT, HTTP LAYER & CHANNELS
|
| 181 |
+
β β β βββ agent.py # init_agent factory + IPv4 patch + backward-compat facade
|
| 182 |
+
β β β βββ api.py # Thin FastAPI wiring (/chat, /config, /tts, admin, health)
|
| 183 |
+
β β β βββ bot.py # Telegram bot (uses shared AgentRunner.sync_reply)
|
| 184 |
+
β β β βββ ingest.py # ChromaDB knowledge ingestion (data/ -> db/)
|
| 185 |
+
β β β
|
| 186 |
+
β β βββ main.py # FastAPI entrypoint (mounts v1 routers)
|
| 187 |
+
β β
|
| 188 |
+
β βββ pyproject.toml
|
| 189 |
+
β βββ Dockerfile
|
| 190 |
+
β
|
| 191 |
+
βββ frontend/ # π¨ NEXT.JS 16 FRONTEND WEB APP (100% PRESERVED)
|
| 192 |
+
β βββ app/
|
| 193 |
+
β β βββ page.tsx # Main App Router (Arun's Portfolio + Dynamic Tenant Metadata Loader)
|
| 194 |
+
β β βββ layout.tsx # Global layout provider
|
| 195 |
+
β β βββ globals.css # Global design tokens & CSS root variables
|
| 196 |
+
β β
|
| 197 |
+
β βββ components/
|
| 198 |
+
β β βββ ChatPanel.tsx # Primary Chat Panel & Hero Assistant Card (100% Preserved)
|
| 199 |
+
β β βββ ProjectsView.tsx # Projects Portfolio View (100% Preserved)
|
| 200 |
+
β β βββ ManifestoView.tsx # Manifesto View (100% Preserved)
|
| 201 |
+
β β βββ Header.tsx # Header bar & navigation (100% Preserved)
|
| 202 |
+
β β βββ Sidebar.tsx # Sidebar navigation (100% Preserved)
|
| 203 |
+
β β
|
| 204 |
+
β βββ hooks/ # Custom React Hooks
|
| 205 |
+
β βββ lib/ # Types & API helpers
|
| 206 |
+
β βββ package.json
|
| 207 |
+
β βββ tsconfig.json
|
| 208 |
+
β
|
| 209 |
+
βββ tenants/ # π¦ EXPLICIT TENANT DATA (OUTSIDE GIT REPO / S3 BUCKET)
|
| 210 |
+
β βββ ed_donner/ # Ed Donner Tenant Package
|
| 211 |
+
β β βββ config/ # π SPLIT CONFIG FILES
|
| 212 |
+
β β β βββ brand.json # Hero title, subtitle, colors, avatar path
|
| 213 |
+
β β β βββ agent.json # System prompt, guardrails, enabled_tools array
|
| 214 |
+
β β β βββ chat.json # Quick questions, welcome message
|
| 215 |
+
β β β βββ voice.json # TTS alloy voice specs
|
| 216 |
+
β β β βββ seo.json # Meta title & description
|
| 217 |
+
β β β βββ social.json # Udemy, LinkedIn, X, website links
|
| 218 |
+
β β βββ assets/ # πΌοΈ STATIC ASSETS (avatars, logos, graphics)
|
| 219 |
+
β β β βββ avatar.png
|
| 220 |
+
β β β βββ logo.png
|
| 221 |
+
β β βββ knowledge/ # π RAW KNOWLEDGE (markdown, PDFs, active_learning.json)
|
| 222 |
+
β β βββ courses.md
|
| 223 |
+
β β βββ active_learning.json
|
| 224 |
+
β β
|
| 225 |
+
β βββ tenant_starter/ # Quick starter template for instant 1-min onboarding
|
| 226 |
+
β βββ vector_db/ # Persistent ChromaDB sqlite & vector indexes
|
| 227 |
+
β
|
| 228 |
+
βββ data/ # πΉ DEMO DATA & GLOBAL PROFILES
|
| 229 |
+
β βββ static/ # Arun's static profile & rules of engagement
|
| 230 |
+
β
|
| 231 |
+
βββ docs/ # Operational SOPs & Architecture Playbooks
|
| 232 |
+
βββ scripts/ # Vector ingestion (`ingest_knowledge.py`), evaluation scripts
|
| 233 |
+
βββ tests/ # Automated unit & integration test suites
|
| 234 |
+
βββ docker-compose.yml # Multi-container orchestration (Backend + Redis)
|
| 235 |
+
βββ Makefile # Developer workflow commands
|
| 236 |
+
βββ README.md # Master Repository Overview
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
---
|
| 240 |
+
|
| 241 |
+
## π― Final Master Checklist
|
| 242 |
+
|
| 243 |
+
- [x] **100% Frontend Preservation**: Existing Next.js frontend UI (`page.tsx`, `ChatPanel.tsx`, `ProjectsView.tsx`, `ManifestoView.tsx`, `Header.tsx`) is preserved 100% with zero layout changes!
|
| 244 |
+
- [x] **Zero Functionality Loss**: 100% of existing features (Hybrid RAG, Telegram active learning, TTS, 3-way live human chat notice, admin mode) are retained and upgraded.
|
| 245 |
+
- [x] **Production-Grade Hybrid RAG Engine**: ChromaDB dense vector + BM25 sparse keyword search + Cohere/LLM reranking.
|
| 246 |
+
- [x] **Ultra-Simple Demo Mode**: Query param `?tutor=ed_donner` dynamically populates hero card title, role subtitle, avatar, welcome text, suggested questions, and AI system prompt.
|
| 247 |
+
- [x] **100% AI Agent Control**: Web chat is always handled by the AI Twin. Zero complex live web page human takeovers needed.
|
| 248 |
+
- [x] **Direct Active Learning Loop**: Owner replies directly in Telegram or WhatsApp to train their AI Twin in real time.
|
| 249 |
+
- [x] **Split Targeted Config**: 6 targeted JSON files (`brand`, `agent`, `chat`, `voice`, `seo`, `social`).
|
| 250 |
+
- [x] **Explicit `tenants/` Directory**: External tenant data (`tenants/<id>/config`, `assets`, `knowledge`) isolated outside Git code repo.
|
| 251 |
+
- [x] **ZERO Client `if` Statements**: 100% dynamic `TenantService` resolution in backend.
|
| 252 |
+
- [x] **Config-Driven Tool Registry**: `agent.json` controls `enabled_tools` array.
|
| 253 |
+
- [x] **Separation of Tenant Assets**: `tenants/<id>/assets/` separate from code.
|
| 254 |
+
- [x] **Single-Responsibility Services**: Decoupled `PromptBuilder`, `MemoryManager`, `ToolExecutor`, `AgentRunner`.
|
| 255 |
+
- [x] **Interface Abstractions**: Abstract classes for `VectorStore`, `StateStore`, and `NotificationProvider`.
|
| 256 |
+
- [x] **Domain-Generic Backend**: Backend engine only understands `Tenant`, `Conversation`, `Knowledge`, `Tool`, `Active Learning`, `Voice`.
|
| 257 |
+
- [x] **Self-Documenting Naming**: Explicit module and directory names throughout.
|
backend/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# βοΈ ArunCore Backend Architecture (`backend/`)
|
| 2 |
+
|
| 3 |
+
> **Architecture Spec:** Decoupled Single-Responsibility Services
|
| 4 |
+
> **Framework:** FastAPI / Uvicorn
|
| 5 |
+
> **LLM Engine:** `gpt-4.1-nano` (via LangChain `ChatOpenAI`)
|
| 6 |
+
> **RAG Engine:** ChromaDB + BM25 + Cohere English V3 Reranker
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## π Backend Directory Map
|
| 11 |
+
|
| 12 |
+
```text
|
| 13 |
+
backend/app/
|
| 14 |
+
βββ api/v1/ # REST API Endpoints & Webhooks
|
| 15 |
+
β βββ config.py # GET /api/v1/config (238-key enterprise JSON resolver)
|
| 16 |
+
β βββ router.py # Main API Router
|
| 17 |
+
β βββ voice.py # POST /api/v1/voice/speak & audio STT endpoint
|
| 18 |
+
β βββ webhook.py # POST /api/v1/webhook (Telegram live takeover webhook)
|
| 19 |
+
βββ core/ # Agent Orchestration Core
|
| 20 |
+
β βββ agent.py # LLM init, search_arun_knowledge, get_github_live_data, notify_arun
|
| 21 |
+
β βββ api.py # Legacy API handler compatibility layer
|
| 22 |
+
β βββ bot.py # Telegram bot delivery helpers
|
| 23 |
+
β βββ evaluate.py # Core evaluation functions
|
| 24 |
+
β βββ ingest.py # Local vector store ingestion module
|
| 25 |
+
βββ schemas/ # Pydantic Schemas & Data Contracts
|
| 26 |
+
β βββ chat.py # ChatRequest, ChatResponse
|
| 27 |
+
β βββ tenant.py # TenantConfig schema
|
| 28 |
+
β βββ voice.py # VoiceSpeakRequest, VoiceSpeakResponse
|
| 29 |
+
β βββ webhook.py # TelegramWebhookPayload
|
| 30 |
+
βββ services/ # Decoupled Business Logic Services
|
| 31 |
+
β βββ active_learning_service.py # Unknown question logger & memory saver
|
| 32 |
+
β βββ memory_manager.py # RollingMemory summary compression engine
|
| 33 |
+
β βββ notification_service.py # Telegram alert queue & delivery service
|
| 34 |
+
β βββ prompt_builder.py # Dynamic System Prompt assembler
|
| 35 |
+
β βββ rag_service.py # Hybrid Vector Search + BM25 + Reranker pipeline
|
| 36 |
+
β βββ tenant_service.py # Dynamic JSON config loader (Zero backend `if` checks)
|
| 37 |
+
β βββ tool_executor.py # Config-driven tool binder & execution loop
|
| 38 |
+
β βββ voice_service.py # OpenAI tts-1 audio synthesizer
|
| 39 |
+
βββ main.py # FastAPI Application Entrypoint
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## π Primary API Endpoints
|
| 45 |
+
|
| 46 |
+
### 1. `GET /api/v1/config?tutor=<tutor_id>`
|
| 47 |
+
- Resolves tenant configuration dynamically from `demos/<tutor_id>_enterprise_dictionary.json`.
|
| 48 |
+
- Returns full 238-key JSON payload including `theme_design_system`, `frontend_ui_dictionary`, `courses`, and `custom_system_prompt`.
|
| 49 |
+
|
| 50 |
+
### 2. `POST /chat` / `POST /api/v1/chat`
|
| 51 |
+
- Accepts user messages and executes the 7-iteration ReAct tool loop (`search_arun_knowledge`, `get_github_live_data`, `notify_arun`).
|
| 52 |
+
- Streams or returns the final response along with tool execution traces.
|
| 53 |
+
|
| 54 |
+
### 3. `POST /api/v1/voice/speak`
|
| 55 |
+
- Synthesizes text into natural studio neural speech audio using OpenAI `tts-1` (`alloy` voice).
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## π§ͺ Testing Backend Services
|
| 60 |
+
|
| 61 |
+
Run all backend test cases:
|
| 62 |
+
```bash
|
| 63 |
+
python3 -m unittest discover -s tests
|
| 64 |
+
```
|
backend/app/api/v1/config.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from fastapi import APIRouter
|
| 3 |
+
from backend.app.services.tenant_service import tenant_service
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@router.get("/config")
|
| 9 |
+
@router.get("/api/v1/config")
|
| 10 |
+
async def get_tutor_config_endpoint(tutor: Optional[str] = None):
|
| 11 |
+
"""Dynamic multi-tenant metadata resolution endpoint powering 1-click demos (?tutor=ed_donner)."""
|
| 12 |
+
full_cfg = tenant_service.load_tenant_config(tutor)
|
| 13 |
+
return full_cfg.to_legacy_dict()
|
backend/app/api/v1/router.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from backend.app.api.v1.config import router as config_router
|
| 3 |
+
from backend.app.api.v1.voice import router as voice_router
|
| 4 |
+
from backend.app.api.v1.webhook import router as webhook_router
|
| 5 |
+
|
| 6 |
+
api_v1_router = APIRouter()
|
| 7 |
+
api_v1_router.include_router(config_router)
|
| 8 |
+
api_v1_router.include_router(voice_router)
|
| 9 |
+
api_v1_router.include_router(webhook_router)
|
backend/app/api/v1/voice.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
from fastapi import APIRouter, HTTPException
|
| 3 |
+
from fastapi.responses import StreamingResponse
|
| 4 |
+
from backend.app.schemas.voice import TTSRequest
|
| 5 |
+
from backend.app.services.voice_service import VoiceService
|
| 6 |
+
|
| 7 |
+
router = APIRouter()
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@router.post("/tts")
|
| 11 |
+
@router.post("/api/v1/voice/tts")
|
| 12 |
+
async def tts_endpoint(req: TTSRequest):
|
| 13 |
+
if not req.text.strip():
|
| 14 |
+
raise HTTPException(status_code=400, detail="Text snippet cannot be empty.")
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
audio_bytes = VoiceService.generate_tts_audio(req.text, voice=req.voice or "alloy")
|
| 18 |
+
return StreamingResponse(io.BytesIO(audio_bytes), media_type="audio/mpeg")
|
| 19 |
+
except Exception as e:
|
| 20 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/app/api/v1/webhook.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from backend.app.schemas.webhook import ActiveLearningWebhookPayload
|
| 3 |
+
from backend.app.services.active_learning_service import ActiveLearningService
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@router.post("/webhook/telegram")
|
| 9 |
+
@router.post("/api/v1/webhook")
|
| 10 |
+
async def telegram_webhook_endpoint(payload: ActiveLearningWebhookPayload):
|
| 11 |
+
try:
|
| 12 |
+
active_learning = ActiveLearningService(tenant_id=payload.tutor_id or "arun")
|
| 13 |
+
success = active_learning.process_incoming_owner_reply(
|
| 14 |
+
session_id=payload.session_id,
|
| 15 |
+
question=payload.user_question,
|
| 16 |
+
answer=payload.owner_answer,
|
| 17 |
+
)
|
| 18 |
+
return {"status": "success" if success else "failed", "session_id": payload.session_id}
|
| 19 |
+
except Exception as e:
|
| 20 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/app/core/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# βοΈ Core Engine (`/core/`)
|
| 2 |
+
|
| 3 |
+
This folder contains the **Python backend engine** for ArunCore. It handles the FastAPI API server, AI reasoning loop (`gpt-4.1-nano`), vector database search, OpenAI studio voice synthesis, real-time GitHub data fetching, **100% automated Telegram notifications**, a **1-Click Magic Link 3-Way Real Human Takeover Engine**, a **Deterministic Human Control State Machine**, and the **Active Learning Memory Loop**.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## π Files Overview & Descriptions
|
| 8 |
+
|
| 9 |
+
### 1. π `core/api.py` β FastAPI Web Server & 3-Way Live Chat Engine
|
| 10 |
+
- Runs the async HTTP server (`port 8000` locally, `port 7860` on Hugging Face Spaces).
|
| 11 |
+
- **Key Endpoints**:
|
| 12 |
+
- `POST /chat`: Streams real-time AI responses token-by-token (NDJSON) with execution trace thoughts. Unconditionally queues automated Telegram chat alerts for EVERY visitor message.
|
| 13 |
+
- `GET /chat/history`: Returns full central session chat transcript (`SESSION_CHAT_STORE`) so both visitor and Real Arun see the exact same 3-way conversation history in real time.
|
| 14 |
+
- `POST /chat/human-message`: Receives live messages sent by Real Arun from the Admin Reply Bar or Telegram commands (`/answer`, `/release`). Automatically pairs Real Arun's answer with the visitor's question, appends to `data/raw/unknown_questions.json`, and triggers background ChromaDB vector DB re-ingestion!
|
| 15 |
+
- `GET /chat/verify-admin-token`: Validates secure HMAC admin tokens generated for 1-click Telegram magic join links.
|
| 16 |
+
- `POST /tts`: Converts AI text into HD neural studio voice using OpenAI `tts-1` (`alloy` voice).
|
| 17 |
+
- `GET /health`: Health check endpoint showing active sessions, Telegram log statuses, and system uptime.
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
### 2. π§ `core/agent.py` β AI Persona, Agentic Loop & Tools
|
| 22 |
+
- Configures the main LLM (`gpt-4.1-nano`), persona system prompt, and tool functions.
|
| 23 |
+
- **Dynamic 100% Language Matching**: Responds in 100% articulate English for English queries with zero Hindi/Hinglish slang leaks. Responds naturally in Hinglish/Hindi when the user speaks Hindi.
|
| 24 |
+
- **3-Way Human Presence Injection**: When Real Arun is active in a session, dynamically injects a system notice containing Real Arun's messages into the prompt context so the AI Twin recognizes Real Arun's presence and co-pilots seamlessly.
|
| 25 |
+
- **Tools**:
|
| 26 |
+
- `search_arun_knowledge`: Alias-aware project search (resolves `MedCoach` β `med_coach/README.md`), always includes `public_profile.md` and checks `data/raw/unknown_questions.json` for Arun's verified human answers.
|
| 27 |
+
- `get_github_live_data`: Fetches real-time public repositories and recent commit activity from GitHub (`api.github.com/users/neural-arun/repos`).
|
| 28 |
+
- `notify_arun`: Sends instant Telegram alerts (`@ai_twin_alert_bot`) for leads or urgent queries.
|
| 29 |
+
- **1-Click Magic Link Generation**: `generate_admin_token(session_id)` & `verify_admin_token(session_id, token)` create tamper-proof magic links (`https://aruncore.vercel.app/?session_id=...&admin_token=...`).
|
| 30 |
+
- **Active Learning**: `save_unknown_question_answer(question, answer)` appends verified Q&A pairs to `data/raw/unknown_questions.json` and triggers background re-ingestion.
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
### 3. π `core/ingest.py` β Vector Database Compiler
|
| 35 |
+
- Scans all markdown and JSON files inside `data/`, chunks them semantically, generates vector embeddings (`text-embedding-3-small`), and compiles into ChromaDB (`db/`).
|
| 36 |
+
- **Smart JSON Parsing**: Specially parses `data/raw/unknown_questions.json` β each Q&A pair becomes its own searchable vector chunk.
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
### 4. π€ `core/bot.py` β Public Telegram Bot Service & Active Learning Handler
|
| 41 |
+
- Runs a standalone Telegram bot allowing visitors to interact with Arun's AI Assistant directly in Telegram.
|
| 42 |
+
- **Active Learning Reply Handler**: When Arun replies to an alert message in Telegram:
|
| 43 |
+
1. Extracts original user question from alert message.
|
| 44 |
+
2. Pairs with Arun's reply as verified answer.
|
| 45 |
+
3. Saves to `data/raw/unknown_questions.json`.
|
| 46 |
+
4. Triggers background ChromaDB re-ingestion.
|
| 47 |
+
5. Sends Arun confirmation: *"β
Answer Saved & Ingested into AI Memory!"*
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
### 5. π§ͺ `core/evaluate.py` β Evaluation & Benchmarking Engine
|
| 52 |
+
- Stress-tests benchmark evaluations against test sets to measure retrieval precision and generation quality.
|
backend/app/core/__init__.py
ADDED
|
File without changes
|
backend/app/core/agent.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent composition root + backwards-compatible facade.
|
| 2 |
+
|
| 3 |
+
The heavy lifting previously in this file now lives in single-responsibility
|
| 4 |
+
services (notification, knowledge, memory, prompt, tools, tenant). This module
|
| 5 |
+
only: (1) applies the global IPv4-only DNS patch once, (2) exposes `init_agent`
|
| 6 |
+
as the small factory that wires an LLM + bound tools + chat prompt + memory,
|
| 7 |
+
and (3) re-exports every public symbol the API, Telegram bot, eval scripts,
|
| 8 |
+
and tests still import from `backend.app.core.agent`.
|
| 9 |
+
"""
|
| 10 |
+
import os
|
| 11 |
+
import socket
|
| 12 |
+
from typing import Any, Dict, Optional, Tuple
|
| 13 |
+
|
| 14 |
+
# Force IPv4-only DNS resolution (Hugging Face Spaces / dual-stack hosts can
|
| 15 |
+
# stall IPv6 lookups). Applied once at import time, mirroring the legacy boot.
|
| 16 |
+
try:
|
| 17 |
+
_orig_getaddrinfo = socket.getaddrinfo
|
| 18 |
+
|
| 19 |
+
def _ipv4_only_getaddrinfo(*args, **kwargs):
|
| 20 |
+
res = _orig_getaddrinfo(*args, **kwargs)
|
| 21 |
+
ipv4_res = [r for r in res if r[0] == socket.AF_INET]
|
| 22 |
+
return ipv4_res or res
|
| 23 |
+
|
| 24 |
+
socket.getaddrinfo = _ipv4_only_getaddrinfo
|
| 25 |
+
except Exception:
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
from dotenv import load_dotenv
|
| 29 |
+
from langchain_openai import ChatOpenAI
|
| 30 |
+
|
| 31 |
+
from backend.app.services.memory_manager import RollingMemory, MemoryManager
|
| 32 |
+
from backend.app.services.prompt_builder import PromptBuilder
|
| 33 |
+
from backend.app.services.tool_executor import (
|
| 34 |
+
ToolExecutor,
|
| 35 |
+
search_arun_knowledge,
|
| 36 |
+
get_github_live_data,
|
| 37 |
+
notify_arun,
|
| 38 |
+
)
|
| 39 |
+
from backend.app.services.tenant_service import tenant_service, TenantService
|
| 40 |
+
from backend.app.services.knowledge_service import knowledge_service
|
| 41 |
+
from backend.app.services.notification_service import (
|
| 42 |
+
queue_debug_event,
|
| 43 |
+
queue_maybe_notify_arun,
|
| 44 |
+
queue_chat_history_to_telegram,
|
| 45 |
+
queue_automated_chat_alert,
|
| 46 |
+
send_automated_chat_alert,
|
| 47 |
+
send_chat_history_to_telegram,
|
| 48 |
+
send_debug_event_to_telegram,
|
| 49 |
+
schedule_notify_arun,
|
| 50 |
+
TELEGRAM_DELIVERY_LOGS,
|
| 51 |
+
_deliver_notify_arun,
|
| 52 |
+
)
|
| 53 |
+
from backend.app.services.auth_service import generate_admin_token, verify_admin_token
|
| 54 |
+
from backend.app.services.agent_runner import AgentRunner, agent_runner, run_pre_escalation
|
| 55 |
+
|
| 56 |
+
load_dotenv()
|
| 57 |
+
|
| 58 |
+
__all__ = [
|
| 59 |
+
"init_agent",
|
| 60 |
+
"RollingMemory",
|
| 61 |
+
"MemoryManager",
|
| 62 |
+
"load_static_context",
|
| 63 |
+
"load_tutor_config",
|
| 64 |
+
"save_unknown_question_answer",
|
| 65 |
+
"queue_debug_event",
|
| 66 |
+
"queue_maybe_notify_arun",
|
| 67 |
+
"run_pre_escalation",
|
| 68 |
+
"queue_chat_history_to_telegram",
|
| 69 |
+
"queue_automated_chat_alert",
|
| 70 |
+
"send_automated_chat_alert",
|
| 71 |
+
"send_chat_history_to_telegram",
|
| 72 |
+
"send_debug_event_to_telegram",
|
| 73 |
+
"schedule_notify_arun",
|
| 74 |
+
"generate_admin_token",
|
| 75 |
+
"verify_admin_token",
|
| 76 |
+
"TELEGRAM_DELIVERY_LOGS",
|
| 77 |
+
"search_arun_knowledge",
|
| 78 |
+
"get_github_live_data",
|
| 79 |
+
"notify_arun",
|
| 80 |
+
"ToolExecutor",
|
| 81 |
+
"AgentRunner",
|
| 82 |
+
"agent_runner",
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def init_agent(
|
| 87 |
+
temperature: float = 0.4,
|
| 88 |
+
model_name: Optional[str] = None,
|
| 89 |
+
tutor_id: Optional[str] = None,
|
| 90 |
+
):
|
| 91 |
+
"""Build a ready-to-run ArunCore agent.
|
| 92 |
+
|
| 93 |
+
Returns the same 4-tuple as the legacy factory:
|
| 94 |
+
(main_llm, chat_prompt, memory, tools).
|
| 95 |
+
"""
|
| 96 |
+
openai_key = os.getenv("OPENAI_API_KEY")
|
| 97 |
+
if not openai_key:
|
| 98 |
+
raise ValueError("OPENAI_API_KEY is not set in environment.")
|
| 99 |
+
|
| 100 |
+
resolved_model = model_name or os.getenv("OPENAI_MODEL", "gpt-4.1-nano")
|
| 101 |
+
|
| 102 |
+
tools = ToolExecutor.get_enabled_tools(ToolExecutor.DEFAULT_TOOLS)
|
| 103 |
+
main_llm = ChatOpenAI(
|
| 104 |
+
temperature=temperature,
|
| 105 |
+
model=resolved_model,
|
| 106 |
+
api_key=openai_key,
|
| 107 |
+
).bind_tools(tools)
|
| 108 |
+
|
| 109 |
+
system_prompt = PromptBuilder().build_system_prompt(tutor_id=tutor_id)
|
| 110 |
+
prompt = PromptBuilder.build_chat_prompt(system_prompt)
|
| 111 |
+
|
| 112 |
+
summary_llm = ChatOpenAI(
|
| 113 |
+
temperature=0.0,
|
| 114 |
+
model="gpt-4o-mini",
|
| 115 |
+
api_key=openai_key,
|
| 116 |
+
)
|
| 117 |
+
memory = RollingMemory(summary_llm=summary_llm)
|
| 118 |
+
|
| 119 |
+
return main_llm, prompt, memory, tools
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def load_tutor_config(tutor_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
| 123 |
+
"""Legacy demos-dictionary loader (kept for external consumers)."""
|
| 124 |
+
return tenant_service.load_legacy_tutor_config(tutor_id)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def save_unknown_question_answer(question: str, answer: str) -> str:
|
| 128 |
+
"""Persists a verified Q&A pair into the active-learning store."""
|
| 129 |
+
return knowledge_service.save_verified_answer(question, answer)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def load_static_context():
|
| 133 |
+
"""Canonical 5-tuple static context reader (system, guardrails, handoff, profile, rules)."""
|
| 134 |
+
from backend.app.services.prompt_builder import load_static_context as _load_5
|
| 135 |
+
|
| 136 |
+
return _load_5()
|
backend/app/core/api.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ArunCore FastAPI HTTP layer.
|
| 2 |
+
|
| 3 |
+
This file only wires HTTP: request validation, routing, CORS, and static file
|
| 4 |
+
serving. Every piece of business logic lives in single-responsibility services
|
| 5 |
+
and is delegated here (agent runner loops, session store, notification, voice,
|
| 6 |
+
tenant config, auth, knowledge persistence).
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
import json
|
| 10 |
+
import io
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
from fastapi import FastAPI, HTTPException
|
| 15 |
+
from pydantic import BaseModel
|
| 16 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
+
from fastapi.responses import StreamingResponse
|
| 18 |
+
|
| 19 |
+
from backend.app.core.agent import init_agent, verify_admin_token
|
| 20 |
+
from backend.app.services.session_store import session_store
|
| 21 |
+
from backend.app.services.agent_runner import agent_runner
|
| 22 |
+
from backend.app.services.knowledge_service import knowledge_service
|
| 23 |
+
from backend.app.services.tenant_service import tenant_service
|
| 24 |
+
from backend.app.services.notification_service import TELEGRAM_DELIVERY_LOGS
|
| 25 |
+
|
| 26 |
+
load_dotenv()
|
| 27 |
+
|
| 28 |
+
app = FastAPI(title="ArunCore API", description="Stateful Agentic Backend for Arun Yadav's Digital Twin.")
|
| 29 |
+
|
| 30 |
+
# Enable CORS for external frontends (Vercel, custom domains, local dev)
|
| 31 |
+
app.add_middleware(
|
| 32 |
+
CORSMiddleware,
|
| 33 |
+
allow_origins=["*"],
|
| 34 |
+
allow_origin_regex=r"https://.*\.vercel\.app",
|
| 35 |
+
allow_credentials=True,
|
| 36 |
+
allow_methods=["*"],
|
| 37 |
+
allow_headers=["*"],
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Initialize the ArunCore engine once (mirrors legacy boot).
|
| 41 |
+
try:
|
| 42 |
+
print("Initializing ArunCore API Backend...")
|
| 43 |
+
_, _, _, tools = init_agent()
|
| 44 |
+
global_tool_map = {t.name: t for t in tools}
|
| 45 |
+
print("API Backend Initialized Successfully.")
|
| 46 |
+
except Exception as e:
|
| 47 |
+
print(f"Failed to initialize backend: {e}")
|
| 48 |
+
raise e
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ChatRequest(BaseModel):
|
| 52 |
+
session_id: str
|
| 53 |
+
message: str
|
| 54 |
+
tutor_id: Optional[str] = None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class TTSRequest(BaseModel):
|
| 58 |
+
text: str
|
| 59 |
+
voice: Optional[str] = "alloy"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class HumanMessageRequest(BaseModel):
|
| 63 |
+
session_id: str
|
| 64 |
+
admin_token: str
|
| 65 |
+
message: str
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@app.get("/api/config")
|
| 69 |
+
@app.get("/config")
|
| 70 |
+
async def get_tutor_config_endpoint(tutor: Optional[str] = None):
|
| 71 |
+
cfg = tenant_service.load_legacy_tutor_config(tutor)
|
| 72 |
+
if not cfg:
|
| 73 |
+
return {"tutor_id": tutor or "arun", "config": None}
|
| 74 |
+
cfg["tutor_id"] = tutor or "arun"
|
| 75 |
+
return cfg
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@app.post("/chat")
|
| 79 |
+
async def chat_endpoint(req: ChatRequest):
|
| 80 |
+
if not req.message.strip():
|
| 81 |
+
raise HTTPException(status_code=400, detail="Message cannot be empty.")
|
| 82 |
+
|
| 83 |
+
async def generate_response():
|
| 84 |
+
async for chunk in agent_runner.stream_chat(
|
| 85 |
+
session_id=req.session_id,
|
| 86 |
+
message=req.message,
|
| 87 |
+
tutor_id=req.tutor_id,
|
| 88 |
+
tool_map=global_tool_map,
|
| 89 |
+
):
|
| 90 |
+
yield json.dumps(chunk) + "\n"
|
| 91 |
+
|
| 92 |
+
return StreamingResponse(generate_response(), media_type="application/x-ndjson")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@app.post("/tts")
|
| 96 |
+
async def tts_endpoint(req: TTSRequest):
|
| 97 |
+
from backend.app.services.voice_service import VoiceService
|
| 98 |
+
|
| 99 |
+
if not req.text.strip():
|
| 100 |
+
raise HTTPException(status_code=400, detail="Text cannot be empty.")
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
audio_bytes = VoiceService.generate_tts_audio(req.text, voice=req.voice or "alloy")
|
| 104 |
+
return StreamingResponse(io.BytesIO(audio_bytes), media_type="audio/mpeg")
|
| 105 |
+
except Exception as e:
|
| 106 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@app.get("/health")
|
| 110 |
+
def health_check():
|
| 111 |
+
alert_tok = os.getenv("TELEGRAM_ALERT_BOT_TOKEN", "")
|
| 112 |
+
bot_tok = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
| 113 |
+
alert_cid = os.getenv("TELEGRAM_ALERT_CHAT_ID", "")
|
| 114 |
+
bot_cid = os.getenv("TELEGRAM_CHAT_ID")
|
| 115 |
+
|
| 116 |
+
return {
|
| 117 |
+
"status": "ok",
|
| 118 |
+
"active_sessions": session_store.liveness,
|
| 119 |
+
"telegram_alert_bot_preview": f"{alert_tok[:6]}...{alert_tok[-4:]}" if alert_tok else "MISSING",
|
| 120 |
+
"telegram_alert_chat_id": alert_cid or "MISSING",
|
| 121 |
+
"telegram_bot_preview": f"{bot_tok[:6]}...{bot_tok[-4:]}" if bot_tok else "MISSING",
|
| 122 |
+
"telegram_chat_id": bot_cid or "MISSING",
|
| 123 |
+
"telegram_logs": TELEGRAM_DELIVERY_LOGS[-10:],
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@app.post("/chat/human-message")
|
| 128 |
+
async def post_human_message(req: HumanMessageRequest):
|
| 129 |
+
if not verify_admin_token(req.session_id, req.admin_token):
|
| 130 |
+
raise HTTPException(status_code=403, detail="Invalid admin token.")
|
| 131 |
+
|
| 132 |
+
clean_text = (req.message or "").strip()
|
| 133 |
+
if not clean_text:
|
| 134 |
+
raise HTTPException(status_code=400, detail="Message cannot be empty.")
|
| 135 |
+
|
| 136 |
+
lowered = clean_text.lower().strip()
|
| 137 |
+
|
| 138 |
+
# Command: /release or /resume -> Hand control back to AI Twin
|
| 139 |
+
if lowered in ("/release", "/resume"):
|
| 140 |
+
session_store.set_human_control(req.session_id, False)
|
| 141 |
+
rel_entry = session_store.record_message(
|
| 142 |
+
req.session_id, "human_arun",
|
| 143 |
+
"[Handed back auto-response control to AI Twin]",
|
| 144 |
+
name="Arun Yadav",
|
| 145 |
+
)
|
| 146 |
+
return {"status": "success", "entry": rel_entry, "human_control": False}
|
| 147 |
+
|
| 148 |
+
# Command: /answer -> Trigger AI Twin to answer reading the 3-way transcript
|
| 149 |
+
if lowered.startswith("/answer"):
|
| 150 |
+
session_store.set_human_control(req.session_id, True)
|
| 151 |
+
extra_prompt = clean_text[7:].strip()
|
| 152 |
+
cmd_entry = session_store.record_message(
|
| 153 |
+
req.session_id, "human_arun", f"/answer {extra_prompt}".strip(), name="Arun Yadav"
|
| 154 |
+
)
|
| 155 |
+
ai_reply = await agent_runner.trigger_ai_answer(req.session_id, extra_prompt)
|
| 156 |
+
return {"status": "success", "entry": cmd_entry, "ai_reply": ai_reply, "human_control": True}
|
| 157 |
+
|
| 158 |
+
# Real Arun's first human message activates Human Control Mode
|
| 159 |
+
session_store.set_human_control(req.session_id, True)
|
| 160 |
+
entry = session_store.record_message(req.session_id, "human_arun", clean_text, name="Arun Yadav")
|
| 161 |
+
session_store.append_human_message(req.session_id, entry)
|
| 162 |
+
|
| 163 |
+
last_user_msg = session_store.get_last_user_message(req.session_id)
|
| 164 |
+
|
| 165 |
+
if last_user_msg:
|
| 166 |
+
try:
|
| 167 |
+
knowledge_service.save_verified_answer(last_user_msg, clean_text)
|
| 168 |
+
print(f"[RAG AUTO-UPDATE] Saved Q&A pair to unknown_questions.json & triggered ChromaDB re-ingestion.")
|
| 169 |
+
except Exception as e:
|
| 170 |
+
print(f"[RAG AUTO-UPDATE ERROR] Failed to auto-ingest admin answer: {e}")
|
| 171 |
+
|
| 172 |
+
memory = session_store.get_or_create_memory(req.session_id)
|
| 173 |
+
memory.add_interaction(f"[REAL ARUN JOINED LIVE]: {clean_text}", "Acknowledged real Arun input.")
|
| 174 |
+
|
| 175 |
+
return {"status": "success", "entry": entry, "human_control": True}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@app.get("/chat/history")
|
| 179 |
+
async def get_chat_history(session_id: str):
|
| 180 |
+
msgs = session_store.get_history(session_id)
|
| 181 |
+
return {"session_id": session_id, "messages": msgs}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@app.get("/chat/human-messages")
|
| 185 |
+
async def get_human_messages(session_id: str):
|
| 186 |
+
msgs = session_store.get_human_messages(session_id)
|
| 187 |
+
return {"session_id": session_id, "messages": msgs}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@app.get("/chat/verify-admin-token")
|
| 191 |
+
async def verify_admin(session_id: str, admin_token: str):
|
| 192 |
+
valid = verify_admin_token(session_id, admin_token)
|
| 193 |
+
return {"valid": valid, "session_id": session_id}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# Mount static frontend export if built (for Hugging Face Spaces production deployment)
|
| 197 |
+
frontend_out = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "out")
|
| 198 |
+
if os.path.exists(frontend_out):
|
| 199 |
+
from fastapi.staticfiles import StaticFiles
|
| 200 |
+
|
| 201 |
+
app.mount("/", StaticFiles(directory=frontend_out, html=True), name="static_frontend")
|
| 202 |
+
|
| 203 |
+
if __name__ == "__main__":
|
| 204 |
+
import uvicorn
|
| 205 |
+
|
| 206 |
+
port = int(os.getenv("PORT", "8000"))
|
| 207 |
+
uvicorn.run("backend.app.main:app", host="0.0.0.0", port=port, reload=False)
|
backend/app/core/bot.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from langchain_openai import ChatOpenAI
|
| 6 |
+
from telegram import Update
|
| 7 |
+
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
| 8 |
+
|
| 9 |
+
# Import the ArunCore engine (composition root + decoupled services)
|
| 10 |
+
from backend.app.core.agent import init_agent
|
| 11 |
+
from backend.app.services.memory_manager import RollingMemory
|
| 12 |
+
from backend.app.services.agent_runner import agent_runner
|
| 13 |
+
from backend.app.services.knowledge_service import knowledge_service
|
| 14 |
+
|
| 15 |
+
load_dotenv()
|
| 16 |
+
|
| 17 |
+
# === In-Memory Session Store (telegram chat_id -> RollingMemory) ===
|
| 18 |
+
sessions: dict[int, RollingMemory] = {}
|
| 19 |
+
|
| 20 |
+
# Initialize the engine once at startup
|
| 21 |
+
print("Initializing ArunCore Telegram Bot...")
|
| 22 |
+
main_llm, prompt, _, tools = init_agent()
|
| 23 |
+
tool_map = {t.name: t for t in tools}
|
| 24 |
+
print("Bot engine ready.")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_or_create_memory(chat_id: int) -> RollingMemory:
|
| 28 |
+
"""Returns existing memory for this user, or creates a new one."""
|
| 29 |
+
if chat_id not in sessions:
|
| 30 |
+
summary_llm = ChatOpenAI(
|
| 31 |
+
temperature=0.0,
|
| 32 |
+
model="gpt-4o-mini",
|
| 33 |
+
api_key=os.getenv("OPENAI_API_KEY"),
|
| 34 |
+
)
|
| 35 |
+
sessions[chat_id] = RollingMemory(summary_llm=summary_llm)
|
| 36 |
+
return sessions[chat_id]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def run_agent(chat_id: int, user_message: str) -> str:
|
| 40 |
+
"""Runs the full stateful agent loop via the shared AgentRunner."""
|
| 41 |
+
memory = get_or_create_memory(chat_id)
|
| 42 |
+
return agent_runner.sync_reply(
|
| 43 |
+
session_id=str(chat_id),
|
| 44 |
+
user_input=user_message,
|
| 45 |
+
llm=main_llm,
|
| 46 |
+
prompt=prompt,
|
| 47 |
+
memory=memory,
|
| 48 |
+
tool_map=tool_map,
|
| 49 |
+
user_metadata={"channel": "telegram", "chat_id": chat_id},
|
| 50 |
+
max_iterations=3,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# === Telegram Handlers ===
|
| 55 |
+
|
| 56 |
+
async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 57 |
+
welcome = (
|
| 58 |
+
"Hi! I'm *ArunCore*, the AI digital twin of *Arun Yadav*.\n\n"
|
| 59 |
+
"Ask me anything about his projects, skills, or background in AI engineering. "
|
| 60 |
+
"I'm here to give you the real picture."
|
| 61 |
+
)
|
| 62 |
+
await update.message.reply_text(welcome, parse_mode="Markdown")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def format_for_telegram(text: str) -> str:
|
| 66 |
+
"""Converts LLM Markdown into Telegram-safe HTML."""
|
| 67 |
+
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
| 68 |
+
text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', text)
|
| 69 |
+
text = re.sub(r'^###?\s+(.+)$', r'\n<b>\1</b>', text, flags=re.MULTILINE)
|
| 70 |
+
text = re.sub(r'```(?:[a-zA-Z]+)?\n?(.*?)\n?```', r'<pre>\1</pre>', text, flags=re.DOTALL)
|
| 71 |
+
text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
|
| 72 |
+
text = re.sub(r'^[*-]\s+', 'β’ ', text, flags=re.MULTILINE)
|
| 73 |
+
|
| 74 |
+
def link_repl(match):
|
| 75 |
+
label, url = match.groups()
|
| 76 |
+
return f'<a href="{url}">{label}</a>'
|
| 77 |
+
|
| 78 |
+
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', link_repl, text)
|
| 79 |
+
return text.strip()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
| 83 |
+
user_text = update.message.text
|
| 84 |
+
chat_id = update.effective_chat.id
|
| 85 |
+
|
| 86 |
+
# Check if this is a Telegram reply to an Alert message
|
| 87 |
+
if update.message.reply_to_message:
|
| 88 |
+
reply_to_text = update.message.reply_to_message.text or update.message.reply_to_message.caption or ""
|
| 89 |
+
|
| 90 |
+
extracted_question = ""
|
| 91 |
+
if "User Query / Details:" in reply_to_text:
|
| 92 |
+
parts = reply_to_text.split("User Query / Details:")
|
| 93 |
+
if len(parts) > 1:
|
| 94 |
+
extracted_question = parts[1].split("Category:")[0].split("Contact:")[0].split("Chat ID:")[0].strip()
|
| 95 |
+
elif "User Message:" in reply_to_text:
|
| 96 |
+
parts = reply_to_text.split("User Message:")
|
| 97 |
+
if len(parts) > 1:
|
| 98 |
+
extracted_question = parts[1].split("Category:")[0].split("Contact:")[0].split("Chat ID:")[0].strip()
|
| 99 |
+
|
| 100 |
+
if not extracted_question and len(reply_to_text) > 5:
|
| 101 |
+
extracted_question = reply_to_text.split("\n\n")[0].strip()
|
| 102 |
+
|
| 103 |
+
if extracted_question:
|
| 104 |
+
res = knowledge_service.save_verified_answer(extracted_question, user_text)
|
| 105 |
+
confirmation = (
|
| 106 |
+
f"<b>β
Answer Saved & Ingested into AI Memory!</b>\n\n"
|
| 107 |
+
f"<b>Question:</b> <code>{extracted_question}</code>\n"
|
| 108 |
+
f"<b>Your Verified Answer:</b>\n{user_text}\n\n"
|
| 109 |
+
f"<i>Result: {res}</i>"
|
| 110 |
+
)
|
| 111 |
+
await update.message.reply_text(confirmation, parse_mode="HTML")
|
| 112 |
+
return
|
| 113 |
+
|
| 114 |
+
await update.message.chat.send_action("typing")
|
| 115 |
+
reply = await asyncio.to_thread(run_agent, chat_id, user_text)
|
| 116 |
+
|
| 117 |
+
html_reply = format_for_telegram(reply)
|
| 118 |
+
try:
|
| 119 |
+
await update.message.reply_text(html_reply, parse_mode="HTML")
|
| 120 |
+
except Exception:
|
| 121 |
+
await update.message.reply_text(reply)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
token = os.getenv("TELEGRAM_PUBLIC_BOT_TOKEN")
|
| 126 |
+
if not token:
|
| 127 |
+
raise ValueError("TELEGRAM_PUBLIC_BOT_TOKEN not set in .env")
|
| 128 |
+
|
| 129 |
+
application = Application.builder().token(token).build()
|
| 130 |
+
application.add_handler(CommandHandler("start", start_handler))
|
| 131 |
+
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, message_handler))
|
| 132 |
+
|
| 133 |
+
print("ArunCore Telegram Bot is running...")
|
| 134 |
+
application.run_polling()
|
backend/app/core/evaluate.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
# Import the agent logic from core.agent
|
| 8 |
+
from core.agent import init_agent, answer_query
|
| 9 |
+
|
| 10 |
+
# Load environment variables
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 14 |
+
EVAL_SET_PATH = BASE_DIR / "data" / "test_set" / "eval_set.json"
|
| 15 |
+
REPORT_PATH = BASE_DIR / "data" / "test_set" / "evaluation_report.json"
|
| 16 |
+
DEBUG_DIR = BASE_DIR / "evaluation_debug"
|
| 17 |
+
|
| 18 |
+
def fuzzy_match(topic, answer):
|
| 19 |
+
"""
|
| 20 |
+
Check if a topic sounds like it's in the answer.
|
| 21 |
+
More lenient than strict substring.
|
| 22 |
+
"""
|
| 23 |
+
topic_clean = topic.lower().strip()
|
| 24 |
+
answer_clean = answer.lower().strip()
|
| 25 |
+
|
| 26 |
+
# 1. Direct match
|
| 27 |
+
if topic_clean in answer_clean:
|
| 28 |
+
return True
|
| 29 |
+
|
| 30 |
+
# 2. Key word subset check (if all significant words of a topic are in the answer)
|
| 31 |
+
# This helps catch "RAG Pipelines" vs "AI pipelines for RAG"
|
| 32 |
+
stop_words = {"and", "the", "a", "an", "is", "for", "vs", "to", "of", "with"}
|
| 33 |
+
words = [w for w in topic_clean.split() if w not in stop_words]
|
| 34 |
+
|
| 35 |
+
if not words: return False
|
| 36 |
+
|
| 37 |
+
matches = sum(1 for w in words if w in answer_clean)
|
| 38 |
+
# If 75% of the important words are there, count it as a pass
|
| 39 |
+
if (matches / len(words)) >= 0.75:
|
| 40 |
+
return True
|
| 41 |
+
|
| 42 |
+
return False
|
| 43 |
+
|
| 44 |
+
def save_detailed_log(qid, question, answer, chunks, retrieval_pass, missing_topics):
|
| 45 |
+
"""Save a clean markdown file for manual human inspection of this specific interaction."""
|
| 46 |
+
os.makedirs(DEBUG_DIR, exist_ok=True)
|
| 47 |
+
filepath = DEBUG_DIR / f"{qid}.md"
|
| 48 |
+
|
| 49 |
+
with open(filepath, "w", encoding="utf-8") as f:
|
| 50 |
+
f.write(f"# Evaluation Log: {qid}\n\n")
|
| 51 |
+
f.write(f"## Question\n{question}\n\n")
|
| 52 |
+
f.write(f"## Status\n")
|
| 53 |
+
f.write(f"- **Retrieval Mode:** {'PASS' if retrieval_pass else 'FAIL'}\n")
|
| 54 |
+
f.write(f"- **Generation Mode:** {'PASS' if not missing_topics else 'FAIL'}\n")
|
| 55 |
+
if missing_topics:
|
| 56 |
+
f.write(f"- **Missing Topics:** {', '.join(missing_topics)}\n")
|
| 57 |
+
f.write(f"\n## ArunCore Answer\n{answer}\n\n")
|
| 58 |
+
f.write(f"## Retrieved Chunks (Final Top 5)\n")
|
| 59 |
+
for i, doc in enumerate(chunks):
|
| 60 |
+
f.write(f"### Chunk {i+1} | Source: {doc.metadata.get('source')}\n")
|
| 61 |
+
f.write(f"```text\n{doc.page_content}\n```\n\n")
|
| 62 |
+
|
| 63 |
+
def run_evaluation():
|
| 64 |
+
print("--- ArunCore Dual-Evaluation Pipeline (Fuzzy Match + Rate Limit Handling) ---")
|
| 65 |
+
|
| 66 |
+
# 1. Initialize Agent
|
| 67 |
+
print("Initializing Agent...")
|
| 68 |
+
try:
|
| 69 |
+
vectorstore, bm25_retriever, compressor, llm, prompt = init_agent()
|
| 70 |
+
except Exception as e:
|
| 71 |
+
print(f"Failed to initialize agent: {e}")
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
# 2. Load Eval Set
|
| 75 |
+
if not EVAL_SET_PATH.exists():
|
| 76 |
+
print(f"Eval set not found at {EVAL_SET_PATH}")
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
with open(EVAL_SET_PATH, "r", encoding="utf-8") as f:
|
| 80 |
+
eval_set = json.load(f)
|
| 81 |
+
|
| 82 |
+
results = []
|
| 83 |
+
passed_retrieval = 0
|
| 84 |
+
passed_generation = 0
|
| 85 |
+
total = len(eval_set)
|
| 86 |
+
|
| 87 |
+
print(f"Starting evaluation of {total} questions...\n")
|
| 88 |
+
|
| 89 |
+
for i, test in enumerate(eval_set):
|
| 90 |
+
qid = test.get("id", f"Q{i}")
|
| 91 |
+
question = test.get("question")
|
| 92 |
+
expected_source = test.get("expected_source")
|
| 93 |
+
expected_topics = test.get("expected_topics", [])
|
| 94 |
+
|
| 95 |
+
print(f"[{i+1}/{total}] Evaluating {qid}: {question[:60]}...")
|
| 96 |
+
|
| 97 |
+
# Execute Agent
|
| 98 |
+
try:
|
| 99 |
+
# We add a delay to satisfy the 10/min Cohere Trial Limit
|
| 100 |
+
if i > 0:
|
| 101 |
+
print(f" (Rate limit cool-down: 6.5s)")
|
| 102 |
+
time.sleep(6.5)
|
| 103 |
+
|
| 104 |
+
response = answer_query(question, vectorstore, bm25_retriever, compressor, llm, prompt)
|
| 105 |
+
answer = response["answer"]
|
| 106 |
+
chunks = response["retrieved_chunks"]
|
| 107 |
+
except Exception as e:
|
| 108 |
+
print(f" Error Querying Agent: {e}")
|
| 109 |
+
results.append({
|
| 110 |
+
"id": qid,
|
| 111 |
+
"status": "ERROR",
|
| 112 |
+
"error": str(e)
|
| 113 |
+
})
|
| 114 |
+
continue
|
| 115 |
+
|
| 116 |
+
# --- Layer 1: Retrieval Check ---
|
| 117 |
+
retrieval_pass = False
|
| 118 |
+
if expected_source.startswith("static/"):
|
| 119 |
+
retrieval_pass = True
|
| 120 |
+
else:
|
| 121 |
+
for doc in chunks:
|
| 122 |
+
source_meta = doc.metadata.get("source", "").lower()
|
| 123 |
+
if expected_source.lower() in source_meta:
|
| 124 |
+
retrieval_pass = True
|
| 125 |
+
break
|
| 126 |
+
|
| 127 |
+
if retrieval_pass: passed_retrieval += 1
|
| 128 |
+
|
| 129 |
+
# --- Layer 2: Generation Check ---
|
| 130 |
+
# Fuzzy match for topics
|
| 131 |
+
missing_topics = []
|
| 132 |
+
for topic in expected_topics:
|
| 133 |
+
if not fuzzy_match(topic, answer):
|
| 134 |
+
missing_topics.append(topic)
|
| 135 |
+
|
| 136 |
+
generation_pass = len(missing_topics) == 0
|
| 137 |
+
if generation_pass: passed_generation += 1
|
| 138 |
+
|
| 139 |
+
# Log detailed human-readable file
|
| 140 |
+
save_detailed_log(qid, question, answer, chunks, retrieval_pass, missing_topics)
|
| 141 |
+
|
| 142 |
+
# Store result in summary list
|
| 143 |
+
results.append({
|
| 144 |
+
"id": qid,
|
| 145 |
+
"retrieval": "PASS" if retrieval_pass else "FAIL",
|
| 146 |
+
"generation": "PASS" if generation_pass else "FAIL",
|
| 147 |
+
"missing": missing_topics
|
| 148 |
+
})
|
| 149 |
+
|
| 150 |
+
# 3. Final Report
|
| 151 |
+
report = {
|
| 152 |
+
"summary": {
|
| 153 |
+
"total_questions": total,
|
| 154 |
+
"retrieval_accuracy": f"{(passed_retrieval/total)*100:.2f}%",
|
| 155 |
+
"generation_accuracy": f"{(passed_generation/total)*100:.2f}%",
|
| 156 |
+
},
|
| 157 |
+
"details": results
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
with open(REPORT_PATH, "w", encoding="utf-8") as f:
|
| 161 |
+
json.dump(report, f, indent=4)
|
| 162 |
+
|
| 163 |
+
print("\n" + "="*40)
|
| 164 |
+
print("EVALUATION COMPLETE")
|
| 165 |
+
print(f"Retrieval Accuracy: {report['summary']['retrieval_accuracy']}")
|
| 166 |
+
print(f"Generation Accuracy: {report['summary']['generation_accuracy']}")
|
| 167 |
+
print(f"Detailed logs saved to: {DEBUG_DIR}")
|
| 168 |
+
print("="*40)
|
| 169 |
+
|
| 170 |
+
if __name__ == "__main__":
|
| 171 |
+
run_evaluation()
|
backend/app/core/ingest.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import hashlib
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Dict, List
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
from langchain_openai import OpenAIEmbeddings
|
| 9 |
+
from langchain_community.vectorstores import Chroma
|
| 10 |
+
from langchain_text_splitters import (
|
| 11 |
+
MarkdownHeaderTextSplitter,
|
| 12 |
+
RecursiveCharacterTextSplitter,
|
| 13 |
+
)
|
| 14 |
+
from langchain_core.documents import Document
|
| 15 |
+
|
| 16 |
+
# Load environment variables from .env file if it exists
|
| 17 |
+
load_dotenv()
|
| 18 |
+
|
| 19 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 20 |
+
DATA_DIR = BASE_DIR / "data"
|
| 21 |
+
DB_DIR = BASE_DIR / "db"
|
| 22 |
+
STATE_FILE = DB_DIR / "ingestion_state.json"
|
| 23 |
+
|
| 24 |
+
# Folders to parse for the Vector DB
|
| 25 |
+
INGEST_DIRS = ["github", "raw", "linkedin", "static"]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_file_hash(filepath: Path) -> str:
|
| 29 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 30 |
+
return hashlib.md5(f.read().encode("utf-8")).hexdigest()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_state() -> Dict[str, Dict]:
|
| 34 |
+
"""
|
| 35 |
+
Returns state in this normalized format:
|
| 36 |
+
{
|
| 37 |
+
"data/static/public_profile.md": {
|
| 38 |
+
"hash": "...",
|
| 39 |
+
"chunk_ids": ["static_N/A_public_profile_chunk_0", ...]
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
Supports old state format too:
|
| 44 |
+
{
|
| 45 |
+
"data/static/public_profile.md": "old_md5_hash"
|
| 46 |
+
}
|
| 47 |
+
"""
|
| 48 |
+
if not STATE_FILE.exists():
|
| 49 |
+
return {}
|
| 50 |
+
|
| 51 |
+
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
| 52 |
+
raw_state = json.load(f)
|
| 53 |
+
|
| 54 |
+
if isinstance(raw_state, dict) and "files" in raw_state and isinstance(raw_state["files"], dict):
|
| 55 |
+
normalized = {}
|
| 56 |
+
for rel_path, entry in raw_state["files"].items():
|
| 57 |
+
if isinstance(entry, dict):
|
| 58 |
+
normalized[rel_path] = {
|
| 59 |
+
"hash": entry.get("hash", ""),
|
| 60 |
+
"chunk_ids": entry.get("chunk_ids", []),
|
| 61 |
+
}
|
| 62 |
+
return normalized
|
| 63 |
+
|
| 64 |
+
normalized = {}
|
| 65 |
+
if isinstance(raw_state, dict):
|
| 66 |
+
for rel_path, value in raw_state.items():
|
| 67 |
+
if isinstance(value, str):
|
| 68 |
+
normalized[rel_path] = {
|
| 69 |
+
"hash": value,
|
| 70 |
+
"chunk_ids": [],
|
| 71 |
+
}
|
| 72 |
+
elif isinstance(value, dict):
|
| 73 |
+
normalized[rel_path] = {
|
| 74 |
+
"hash": value.get("hash", ""),
|
| 75 |
+
"chunk_ids": value.get("chunk_ids", []),
|
| 76 |
+
}
|
| 77 |
+
return normalized
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def save_state(state: Dict[str, Dict]) -> None:
|
| 81 |
+
os.makedirs(DB_DIR, exist_ok=True)
|
| 82 |
+
payload = {
|
| 83 |
+
"version": 2,
|
| 84 |
+
"files": state,
|
| 85 |
+
}
|
| 86 |
+
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
| 87 |
+
json.dump(payload, f, indent=4)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def process_markdown_file(filepath: Path, base_folder: str, rel_path: str) -> List[Document]:
|
| 91 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 92 |
+
text = f.read()
|
| 93 |
+
|
| 94 |
+
headers_to_split_on = [
|
| 95 |
+
("#", "Header 1"),
|
| 96 |
+
("##", "Header 2"),
|
| 97 |
+
("###", "Header 3"),
|
| 98 |
+
]
|
| 99 |
+
markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
|
| 100 |
+
md_header_splits = markdown_splitter.split_text(text)
|
| 101 |
+
|
| 102 |
+
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1200, chunk_overlap=150)
|
| 103 |
+
splits = text_splitter.split_documents(md_header_splits)
|
| 104 |
+
|
| 105 |
+
safe_name = filepath.stem.replace(" ", "_").replace(".", "_")
|
| 106 |
+
|
| 107 |
+
project_name = "N/A"
|
| 108 |
+
parts = list(filepath.relative_to(DATA_DIR).parts)
|
| 109 |
+
if "github" in parts and len(parts) > 1:
|
| 110 |
+
project_name = parts[1]
|
| 111 |
+
|
| 112 |
+
for i, split in enumerate(splits):
|
| 113 |
+
split.metadata["source"] = rel_path
|
| 114 |
+
split.metadata["folder"] = base_folder
|
| 115 |
+
split.metadata["project"] = project_name
|
| 116 |
+
split.metadata["chunk_id"] = f"{base_folder}_{project_name}_{safe_name}_chunk_{i}"
|
| 117 |
+
|
| 118 |
+
return splits
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def process_json_file(filepath: Path, base_folder: str, rel_path: str) -> List[Document]:
|
| 122 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 123 |
+
try:
|
| 124 |
+
data = json.load(f)
|
| 125 |
+
except Exception:
|
| 126 |
+
data = None
|
| 127 |
+
|
| 128 |
+
if filepath.name == "unknown_questions.json" and isinstance(data, list):
|
| 129 |
+
docs = []
|
| 130 |
+
for i, item in enumerate(data):
|
| 131 |
+
if isinstance(item, dict) and "question" in item and "answer" in item:
|
| 132 |
+
q = item.get("question", "").strip()
|
| 133 |
+
a = item.get("answer", "").strip()
|
| 134 |
+
content = f"### Verified Question & Answer\n**Question:** {q}\n**Answer:** {a}"
|
| 135 |
+
docs.append(
|
| 136 |
+
Document(
|
| 137 |
+
page_content=content,
|
| 138 |
+
metadata={
|
| 139 |
+
"source": rel_path,
|
| 140 |
+
"folder": base_folder,
|
| 141 |
+
"project": "unknown_questions",
|
| 142 |
+
"chunk_id": f"unknown_q_{i}",
|
| 143 |
+
},
|
| 144 |
+
)
|
| 145 |
+
)
|
| 146 |
+
if docs:
|
| 147 |
+
return docs
|
| 148 |
+
|
| 149 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 150 |
+
content = f.read()
|
| 151 |
+
|
| 152 |
+
project_name = "N/A"
|
| 153 |
+
parts = list(filepath.relative_to(DATA_DIR).parts)
|
| 154 |
+
if "github" in parts and len(parts) > 1:
|
| 155 |
+
project_name = parts[1]
|
| 156 |
+
|
| 157 |
+
safe_name = filepath.stem.replace(" ", "_").replace(".", "_")
|
| 158 |
+
chunk_id = f"{base_folder}_{project_name}_{safe_name}_chunk_0"
|
| 159 |
+
|
| 160 |
+
doc = Document(
|
| 161 |
+
page_content=content,
|
| 162 |
+
metadata={
|
| 163 |
+
"source": rel_path,
|
| 164 |
+
"folder": base_folder,
|
| 165 |
+
"project": project_name,
|
| 166 |
+
"chunk_id": chunk_id,
|
| 167 |
+
},
|
| 168 |
+
)
|
| 169 |
+
return [doc]
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def process_file(filepath: Path, base_folder: str, rel_path: str) -> List[Document]:
|
| 173 |
+
if filepath.suffix == ".md":
|
| 174 |
+
return process_markdown_file(filepath, base_folder, rel_path)
|
| 175 |
+
if filepath.suffix == ".json":
|
| 176 |
+
return process_json_file(filepath, base_folder, rel_path)
|
| 177 |
+
return []
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def main():
|
| 181 |
+
if not os.getenv("OPENAI_API_KEY"):
|
| 182 |
+
print("\n[ERROR] OPENAI_API_KEY not found in the environment.")
|
| 183 |
+
print("Please set it or create a .env file containing OPENAI_API_KEY=your_key_here")
|
| 184 |
+
return
|
| 185 |
+
|
| 186 |
+
print("Initializing ChromaDB and OpenAI Embeddings...")
|
| 187 |
+
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
|
| 188 |
+
|
| 189 |
+
vectorstore = Chroma(
|
| 190 |
+
collection_name="aruncore_knowledge",
|
| 191 |
+
embedding_function=embeddings,
|
| 192 |
+
persist_directory=str(DB_DIR),
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
previous_state = load_state()
|
| 196 |
+
new_state: Dict[str, Dict] = {}
|
| 197 |
+
|
| 198 |
+
docs_to_upsert: List[Document] = []
|
| 199 |
+
ids_to_upsert: List[str] = []
|
| 200 |
+
stale_ids_to_delete = set()
|
| 201 |
+
current_rel_paths = set()
|
| 202 |
+
|
| 203 |
+
folders_to_scan = [folder for folder in INGEST_DIRS if (DATA_DIR / folder).is_dir()]
|
| 204 |
+
|
| 205 |
+
for folder in folders_to_scan:
|
| 206 |
+
target_dir = DATA_DIR / folder
|
| 207 |
+
|
| 208 |
+
for ext in ["*.md", "*.json"]:
|
| 209 |
+
for filepath in target_dir.rglob(ext):
|
| 210 |
+
if "test_set" in filepath.parts:
|
| 211 |
+
continue
|
| 212 |
+
|
| 213 |
+
rel_path = filepath.relative_to(BASE_DIR).as_posix()
|
| 214 |
+
current_rel_paths.add(rel_path)
|
| 215 |
+
|
| 216 |
+
file_hash = get_file_hash(filepath)
|
| 217 |
+
previous_entry = previous_state.get(rel_path, {})
|
| 218 |
+
previous_hash = previous_entry.get("hash")
|
| 219 |
+
previous_chunk_ids = previous_entry.get("chunk_ids", [])
|
| 220 |
+
|
| 221 |
+
is_unchanged = (
|
| 222 |
+
previous_hash == file_hash
|
| 223 |
+
and isinstance(previous_chunk_ids, list)
|
| 224 |
+
and len(previous_chunk_ids) > 0
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
if is_unchanged:
|
| 228 |
+
new_state[rel_path] = {
|
| 229 |
+
"hash": previous_hash,
|
| 230 |
+
"chunk_ids": previous_chunk_ids,
|
| 231 |
+
}
|
| 232 |
+
continue
|
| 233 |
+
|
| 234 |
+
print(f"File changed/new -> Processing: {rel_path}")
|
| 235 |
+
|
| 236 |
+
splits = process_file(filepath, folder, rel_path)
|
| 237 |
+
new_chunk_ids = [doc.metadata["chunk_id"] for doc in splits]
|
| 238 |
+
|
| 239 |
+
docs_to_upsert.extend(splits)
|
| 240 |
+
ids_to_upsert.extend(new_chunk_ids)
|
| 241 |
+
|
| 242 |
+
old_ids_set = set(previous_chunk_ids)
|
| 243 |
+
new_ids_set = set(new_chunk_ids)
|
| 244 |
+
|
| 245 |
+
stale_ids_to_delete.update(old_ids_set - new_ids_set)
|
| 246 |
+
|
| 247 |
+
new_state[rel_path] = {
|
| 248 |
+
"hash": file_hash,
|
| 249 |
+
"chunk_ids": new_chunk_ids,
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
deleted_files = set(previous_state.keys()) - current_rel_paths
|
| 253 |
+
for rel_path in deleted_files:
|
| 254 |
+
old_chunk_ids = previous_state.get(rel_path, {}).get("chunk_ids", [])
|
| 255 |
+
if old_chunk_ids:
|
| 256 |
+
print(f"File deleted -> Removing old chunks: {rel_path}")
|
| 257 |
+
stale_ids_to_delete.update(old_chunk_ids)
|
| 258 |
+
|
| 259 |
+
if docs_to_upsert:
|
| 260 |
+
print(f"\nUpserting {len(docs_to_upsert)} new/modified chunks into Vector DB...")
|
| 261 |
+
vectorstore.add_documents(documents=docs_to_upsert, ids=ids_to_upsert)
|
| 262 |
+
print("Upsert complete.")
|
| 263 |
+
else:
|
| 264 |
+
print("\nNo new or modified files to upsert.")
|
| 265 |
+
|
| 266 |
+
if stale_ids_to_delete:
|
| 267 |
+
stale_ids_list = sorted(stale_ids_to_delete)
|
| 268 |
+
print(f"Deleting {len(stale_ids_list)} stale chunks from Vector DB...")
|
| 269 |
+
vectorstore.delete(ids=stale_ids_list)
|
| 270 |
+
print("Stale chunk cleanup complete.")
|
| 271 |
+
else:
|
| 272 |
+
print("No stale chunks to delete.")
|
| 273 |
+
|
| 274 |
+
save_state(new_state)
|
| 275 |
+
print("Ingestion sequence complete.")
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
if __name__ == "__main__":
|
| 279 |
+
main()
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from fastapi import FastAPI
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from backend.app.api.v1.router import api_v1_router
|
| 5 |
+
from backend.app.core.api import app as legacy_app
|
| 6 |
+
|
| 7 |
+
app = legacy_app
|
| 8 |
+
|
| 9 |
+
# Mount versioned API routes
|
| 10 |
+
app.include_router(api_v1_router)
|
| 11 |
+
|
| 12 |
+
if __name__ == "__main__":
|
| 13 |
+
import uvicorn
|
| 14 |
+
port = int(os.getenv("PORT", "8000"))
|
| 15 |
+
uvicorn.run("backend.app.main:app", host="0.0.0.0", port=port, reload=True)
|
backend/app/schemas/chat.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional, Dict, Any
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ChatRequest(BaseModel):
|
| 6 |
+
message: str = Field(..., min_length=1, description="User input message text")
|
| 7 |
+
session_id: str = Field(..., description="Unique session identifier")
|
| 8 |
+
tutor_id: Optional[str] = Field("arun", description="Optional tenant or tutor ID")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ChatMessageEntry(BaseModel):
|
| 12 |
+
id: str
|
| 13 |
+
sender: str
|
| 14 |
+
name: str
|
| 15 |
+
text: str
|
| 16 |
+
timestamp: str
|
| 17 |
+
thoughts: Optional[List[str]] = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ChatHistoryResponse(BaseModel):
|
| 21 |
+
session_id: str
|
| 22 |
+
messages: List[ChatMessageEntry] = Field(default_factory=list)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class NDJSONStreamChunk(BaseModel):
|
| 26 |
+
type: str = Field(..., description="'status', 'token', 'final', or 'error'")
|
| 27 |
+
content: Optional[str] = None
|
| 28 |
+
reply: Optional[str] = None
|
| 29 |
+
thoughts: Optional[List[str]] = None
|
| 30 |
+
session_id: Optional[str] = None
|
backend/app/schemas/tenant.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional, Dict, Any
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class BrandConfig(BaseModel):
|
| 6 |
+
tutor_id: str = Field(..., description="Tenant ID matching directory name")
|
| 7 |
+
name: str = Field("Arun Yadav", description="Tenant full name")
|
| 8 |
+
title: str = Field("Arun's AI Assistant", description="Hero assistant title")
|
| 9 |
+
role: str = Field("AI Systems Architect β’ Healthcare & Education", description="Hero role subtitle")
|
| 10 |
+
subtitle: Optional[str] = None
|
| 11 |
+
avatar_url: str = Field("/profile_photo.png", description="Avatar image URL")
|
| 12 |
+
logo_url: str = Field("/logo.jpg", description="Brand logo URL")
|
| 13 |
+
primary_color: str = Field("#6366f1", description="Primary brand hex color")
|
| 14 |
+
accent_color: str = Field("#818cf8", description="Accent brand hex color")
|
| 15 |
+
theme: str = Field("dark", description="Light or dark theme preference")
|
| 16 |
+
cta_text: str = Field("Consult Arun", description="CTA button label")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class AgentConfig(BaseModel):
|
| 20 |
+
tutor_id: str
|
| 21 |
+
system_prompt: str = Field(..., description="Base system prompt")
|
| 22 |
+
temperature: float = Field(0.3, ge=0.0, le=1.0)
|
| 23 |
+
model: str = Field("gpt-4o", description="LLM model identifier")
|
| 24 |
+
enabled_tools: List[str] = Field(default_factory=list, description="Array of enabled tool names")
|
| 25 |
+
guardrails: List[str] = Field(default_factory=list, description="Safety guardrails")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ChatConfig(BaseModel):
|
| 29 |
+
tutor_id: str
|
| 30 |
+
welcome_message: str = Field(..., description="Hero card welcome text")
|
| 31 |
+
suggested_questions: List[str] = Field(default_factory=list, description="Chips suggested questions")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class VoiceConfig(BaseModel):
|
| 35 |
+
tutor_id: str
|
| 36 |
+
voice_id: str = Field("alloy", description="TTS voice identifier")
|
| 37 |
+
model: str = Field("tts-1", description="TTS model")
|
| 38 |
+
speed: float = Field(1.0, ge=0.25, le=4.0)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class SEOConfig(BaseModel):
|
| 42 |
+
tutor_id: str
|
| 43 |
+
meta_title: str
|
| 44 |
+
meta_description: str
|
| 45 |
+
keywords: List[str] = Field(default_factory=list)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class SocialConfig(BaseModel):
|
| 49 |
+
tutor_id: str
|
| 50 |
+
website: Optional[str] = None
|
| 51 |
+
linkedin: Optional[str] = None
|
| 52 |
+
udemy: Optional[str] = None
|
| 53 |
+
github: Optional[str] = None
|
| 54 |
+
twitter: Optional[str] = None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class TenantFullConfig(BaseModel):
|
| 58 |
+
tutor_id: str
|
| 59 |
+
brand: BrandConfig
|
| 60 |
+
agent: AgentConfig
|
| 61 |
+
chat: ChatConfig
|
| 62 |
+
voice: VoiceConfig
|
| 63 |
+
seo: SEOConfig
|
| 64 |
+
social: SocialConfig
|
| 65 |
+
|
| 66 |
+
def to_legacy_dict(self) -> Dict[str, Any]:
|
| 67 |
+
"""Maps split JSON configs back to existing frontend expected dictionary structure (100% backward compatible!)."""
|
| 68 |
+
return {
|
| 69 |
+
"tutor_id": self.tutor_id,
|
| 70 |
+
"title": self.brand.title,
|
| 71 |
+
"subtitle": self.brand.subtitle or self.brand.role,
|
| 72 |
+
"role": self.brand.role,
|
| 73 |
+
"name": self.brand.name,
|
| 74 |
+
"avatar": self.brand.avatar_url,
|
| 75 |
+
"logo": self.brand.logo_url,
|
| 76 |
+
"welcome_message": self.chat.welcome_message,
|
| 77 |
+
"suggested_questions": self.chat.suggested_questions,
|
| 78 |
+
"cta_text": self.brand.cta_text,
|
| 79 |
+
"primary_color": self.brand.primary_color,
|
| 80 |
+
"accent_color": self.brand.accent_color,
|
| 81 |
+
"system_prompt": self.agent.system_prompt,
|
| 82 |
+
"enabled_tools": self.agent.enabled_tools,
|
| 83 |
+
"frontend_ui_dictionary": {
|
| 84 |
+
"header": {
|
| 85 |
+
"profile_name": self.brand.name,
|
| 86 |
+
"profile_badge": self.brand.role,
|
| 87 |
+
"cta_button": {
|
| 88 |
+
"text": self.brand.cta_text,
|
| 89 |
+
},
|
| 90 |
+
},
|
| 91 |
+
"sidebar": {
|
| 92 |
+
"profile_card": {
|
| 93 |
+
"name": self.brand.name,
|
| 94 |
+
"title": self.brand.role,
|
| 95 |
+
"sub_badge": f"{self.brand.name}'s AI Twin",
|
| 96 |
+
},
|
| 97 |
+
"contact_button": {
|
| 98 |
+
"title": self.brand.cta_text,
|
| 99 |
+
"subtitle": f"Connect directly with {self.brand.name}",
|
| 100 |
+
},
|
| 101 |
+
},
|
| 102 |
+
"chat_panel": {
|
| 103 |
+
"hero_card": {
|
| 104 |
+
"assistant_title": self.brand.title,
|
| 105 |
+
"role_subtitle": self.brand.role,
|
| 106 |
+
"welcome_paragraph": self.chat.welcome_message,
|
| 107 |
+
"cta_button_text": self.brand.cta_text,
|
| 108 |
+
},
|
| 109 |
+
"input_bar": {
|
| 110 |
+
"placeholder": f"Ask {self.brand.name}'s AI Assistant...",
|
| 111 |
+
},
|
| 112 |
+
"suggested_questions_section": {
|
| 113 |
+
"chips": [{"query": q} for q in self.chat.suggested_questions],
|
| 114 |
+
},
|
| 115 |
+
}
|
| 116 |
+
},
|
| 117 |
+
"client_metadata": {
|
| 118 |
+
"tutor_id": self.tutor_id,
|
| 119 |
+
"avatar_url": self.brand.avatar_url,
|
| 120 |
+
"logo_url": self.brand.logo_url,
|
| 121 |
+
}
|
| 122 |
+
}
|
backend/app/schemas/voice.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class TTSRequest(BaseModel):
|
| 6 |
+
text: str = Field(..., min_length=1, max_length=2000, description="Text snippet to synthesize into audio")
|
| 7 |
+
voice: Optional[str] = Field("alloy", description="TTS voice identifier")
|
| 8 |
+
tutor_id: Optional[str] = Field("arun", description="Optional tenant identifier")
|
backend/app/schemas/webhook.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, Dict, Any
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class TelegramUpdate(BaseModel):
|
| 6 |
+
update_id: int
|
| 7 |
+
message: Optional[Dict[str, Any]] = None
|
| 8 |
+
callback_query: Optional[Dict[str, Any]] = None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ActiveLearningWebhookPayload(BaseModel):
|
| 12 |
+
session_id: str
|
| 13 |
+
user_question: str
|
| 14 |
+
owner_answer: str
|
| 15 |
+
tutor_id: Optional[str] = "arun"
|
backend/app/services/active_learning_service.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
from typing import Optional, Dict, Any
|
| 4 |
+
from backend.app.services.rag_service import RAGService
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ActiveLearningService:
|
| 8 |
+
def __init__(self, tenant_id: str = "arun"):
|
| 9 |
+
self.tenant_id = tenant_id
|
| 10 |
+
self.rag_service = RAGService(tenant_id=tenant_id)
|
| 11 |
+
|
| 12 |
+
def process_incoming_owner_reply(self, session_id: str, question: str, answer: str) -> bool:
|
| 13 |
+
"""Processes owner's Telegram/WhatsApp answer, ingests into active_learning.json & ChromaDB."""
|
| 14 |
+
print(f"[ACTIVE_LEARNING] Processing owner answer for session '{session_id}': Q: '{question}' β A: '{answer}'")
|
| 15 |
+
|
| 16 |
+
# Ingest into vector store
|
| 17 |
+
success = self.rag_service.add_knowledge_entry(question, answer)
|
| 18 |
+
return success
|
backend/app/services/agent_runner.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent execution loop (the ReAct harness).
|
| 2 |
+
|
| 3 |
+
Runs the recursive tool-call loop for the ArunCore digital twin: request ->
|
| 4 |
+
optional escalation -> up to N tool iterations -> token-by-token streaming of
|
| 5 |
+
the final synthesis. Also owns the 3-way live human takeover answer trigger.
|
| 6 |
+
This is where the previous `/chat` monolith body now lives, fully reusable by
|
| 7 |
+
the FastAPI layer, the Telegram bot vector, and future channels.
|
| 8 |
+
"""
|
| 9 |
+
import asyncio
|
| 10 |
+
import json
|
| 11 |
+
from typing import Any, AsyncGenerator, Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
from langchain_core.messages import SystemMessage
|
| 14 |
+
|
| 15 |
+
from backend.app.services.session_store import SessionStore, session_store
|
| 16 |
+
from backend.app.services.prompt_builder import PromptBuilder
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def run_pre_escalation(user_input: str, tool_map: dict, user_metadata: Optional[dict] = None, fast: bool = False) -> Dict[str, Any]:
|
| 20 |
+
"""Checks for direct-contact intent that should immediately ping Arun."""
|
| 21 |
+
lowered = (user_input or "").lower()
|
| 22 |
+
urgent_keywords = [
|
| 23 |
+
"hire", "contact", "talk to arun", "call arun", "meet arun",
|
| 24 |
+
"whatsapp", "urgent", "consult", "project inquiry", "work together",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
for kw in urgent_keywords:
|
| 28 |
+
if kw in lowered:
|
| 29 |
+
return {"escalate": True, "reason": f"Urgent contact keyword detected: '{kw}'"}
|
| 30 |
+
|
| 31 |
+
return {"escalate": False, "reason": ""}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class AgentRunner:
|
| 35 |
+
"""Orchestrates agentic streaming responses for a single chat request."""
|
| 36 |
+
|
| 37 |
+
def __init__(
|
| 38 |
+
self,
|
| 39 |
+
store: Optional[SessionStore] = None,
|
| 40 |
+
agent_factory: Any = None,
|
| 41 |
+
max_iterations: int = 7,
|
| 42 |
+
max_search_limit: int = 7,
|
| 43 |
+
):
|
| 44 |
+
self.store = store or session_store
|
| 45 |
+
self.agent_factory = agent_factory
|
| 46 |
+
self.max_iterations = max_iterations
|
| 47 |
+
self.max_search_limit = max_search_limit
|
| 48 |
+
|
| 49 |
+
async def stream_chat(
|
| 50 |
+
self,
|
| 51 |
+
session_id: str,
|
| 52 |
+
message: str,
|
| 53 |
+
tutor_id: Optional[str] = None,
|
| 54 |
+
tool_map: Optional[Dict[str, Any]] = None,
|
| 55 |
+
) -> AsyncGenerator[Dict[str, Any], None]:
|
| 56 |
+
"""Yields NDJSON-able chunks: {'type': status|token|final|error}."""
|
| 57 |
+
thoughts: List[str] = []
|
| 58 |
+
try:
|
| 59 |
+
from backend.app.services.notification_service import (
|
| 60 |
+
queue_debug_event,
|
| 61 |
+
queue_maybe_notify_arun,
|
| 62 |
+
queue_chat_history_to_telegram,
|
| 63 |
+
queue_automated_chat_alert,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
if self.agent_factory is None:
|
| 67 |
+
from backend.app.core.agent import init_agent
|
| 68 |
+
|
| 69 |
+
self.agent_factory = init_agent
|
| 70 |
+
|
| 71 |
+
self.store.record_message(session_id, "user", message)
|
| 72 |
+
|
| 73 |
+
queue_debug_event(
|
| 74 |
+
"user_message",
|
| 75 |
+
message,
|
| 76 |
+
{"channel": "api", "session_id": session_id, "tutor_id": tutor_id},
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# 3-way live chat takeover: real Arun is in control, AI Twin pauses.
|
| 80 |
+
if self.store.is_human_control(session_id):
|
| 81 |
+
queue_automated_chat_alert(
|
| 82 |
+
session_id=session_id,
|
| 83 |
+
user_input=message,
|
| 84 |
+
assistant_response="[Real Arun is currently in live control of this chat session. AI Twin paused.]",
|
| 85 |
+
)
|
| 86 |
+
yield {"type": "status", "content": "π’ Real Arun is in control of this session. AI Twin paused. Waiting for Real Arun or /answer command..."}
|
| 87 |
+
yield {
|
| 88 |
+
"type": "final",
|
| 89 |
+
"reply": "",
|
| 90 |
+
"thoughts": ["Real Arun in live control. AI Twin paused."],
|
| 91 |
+
"session_id": session_id,
|
| 92 |
+
}
|
| 93 |
+
return
|
| 94 |
+
|
| 95 |
+
yield {"type": "status", "content": "Analyzing request & retrieving context..."}
|
| 96 |
+
thoughts.append("Analyzing request & retrieving context...")
|
| 97 |
+
|
| 98 |
+
pre_result = run_pre_escalation(message, tool_map or {})
|
| 99 |
+
if pre_result.get("escalate"):
|
| 100 |
+
yield {"type": "status", "content": "Triggering instant Telegram alert..."}
|
| 101 |
+
thoughts.append("Triggering instant Telegram alert...")
|
| 102 |
+
queue_maybe_notify_arun(
|
| 103 |
+
user_input=message,
|
| 104 |
+
reason=pre_result.get("reason"),
|
| 105 |
+
channel="api",
|
| 106 |
+
session_id=session_id,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
session_llm, session_prompt, _, _ = self.agent_factory(tutor_id=tutor_id)
|
| 110 |
+
memory = self.store.get_or_create_memory(session_id)
|
| 111 |
+
|
| 112 |
+
scratchpad: List[Any] = []
|
| 113 |
+
iterations = 0
|
| 114 |
+
search_count = 0
|
| 115 |
+
final_response = ""
|
| 116 |
+
executed_tools: List[str] = []
|
| 117 |
+
retrieved_chunks: List[str] = []
|
| 118 |
+
github_data: List[str] = []
|
| 119 |
+
|
| 120 |
+
while iterations < self.max_iterations:
|
| 121 |
+
messages = session_prompt.format_messages(
|
| 122 |
+
running_summary=memory.running_summary,
|
| 123 |
+
chat_history=memory.get_messages(),
|
| 124 |
+
input=message,
|
| 125 |
+
agent_scratchpad=scratchpad,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Inject dynamic Real Human Presence notice if the real human is active.
|
| 129 |
+
arun_human_msgs = self.store.get_human_entries(session_id)
|
| 130 |
+
if arun_human_msgs:
|
| 131 |
+
messages = PromptBuilder.inject_live_human_notice(messages, arun_human_msgs)
|
| 132 |
+
|
| 133 |
+
ai_msg = await asyncio.to_thread(session_llm.invoke, messages)
|
| 134 |
+
|
| 135 |
+
if ai_msg.tool_calls:
|
| 136 |
+
scratchpad.append(ai_msg)
|
| 137 |
+
for tc in ai_msg.tool_calls:
|
| 138 |
+
tool_name = tc["name"]
|
| 139 |
+
tool_args = tc.get("args", {})
|
| 140 |
+
|
| 141 |
+
status_msg = (
|
| 142 |
+
"Searching Arun's knowledge base..." if tool_name == "search_arun_knowledge"
|
| 143 |
+
else "Sending notification to Arun..." if tool_name == "notify_arun"
|
| 144 |
+
else f"Executing {tool_name}..."
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
yield {"type": "status", "content": status_msg}
|
| 148 |
+
thoughts.append(status_msg)
|
| 149 |
+
executed_tools.append(f"{tool_name}({tool_args})")
|
| 150 |
+
|
| 151 |
+
if tool_name == "search_arun_knowledge":
|
| 152 |
+
search_count += 1
|
| 153 |
+
|
| 154 |
+
if search_count > self.max_search_limit:
|
| 155 |
+
tool_result = f"Search limit reached ({self.max_search_limit}). Finalizing based on existing context."
|
| 156 |
+
else:
|
| 157 |
+
tool_func = (tool_map or {}).get(tool_name)
|
| 158 |
+
tool_result = await asyncio.to_thread(tool_func.invoke, tool_args) if tool_func else f"Unknown tool: {tool_name}"
|
| 159 |
+
|
| 160 |
+
if tool_name == "search_arun_knowledge" and tool_result:
|
| 161 |
+
retrieved_chunks.append(str(tool_result)[:1000])
|
| 162 |
+
elif tool_name == "get_github_live_data" and tool_result:
|
| 163 |
+
github_data.append(str(tool_result)[:1000])
|
| 164 |
+
|
| 165 |
+
scratchpad.append({
|
| 166 |
+
"role": "tool",
|
| 167 |
+
"name": tool_name,
|
| 168 |
+
"tool_call_id": tc["id"],
|
| 169 |
+
"content": str(tool_result)[:15000],
|
| 170 |
+
})
|
| 171 |
+
iterations += 1
|
| 172 |
+
else:
|
| 173 |
+
yield {"type": "status", "content": "Synthesizing final response..."}
|
| 174 |
+
thoughts.append("Synthesizing final response...")
|
| 175 |
+
|
| 176 |
+
full_reply = ""
|
| 177 |
+
async for chunk in self._stream_tokens(session_llm, messages):
|
| 178 |
+
yield chunk
|
| 179 |
+
if isinstance(chunk, dict) and chunk.get("type") == "token":
|
| 180 |
+
full_reply += chunk["content"]
|
| 181 |
+
|
| 182 |
+
final_response = full_reply
|
| 183 |
+
break
|
| 184 |
+
|
| 185 |
+
if not final_response:
|
| 186 |
+
yield {"type": "status", "content": "Synthesizing final response..."}
|
| 187 |
+
thoughts.append("Synthesizing final response...")
|
| 188 |
+
messages_fallback = session_prompt.format_messages(
|
| 189 |
+
running_summary=memory.running_summary,
|
| 190 |
+
chat_history=memory.get_messages(),
|
| 191 |
+
input=message,
|
| 192 |
+
agent_scratchpad=scratchpad,
|
| 193 |
+
)
|
| 194 |
+
full_reply = ""
|
| 195 |
+
async for chunk in self._stream_tokens(session_llm, messages_fallback):
|
| 196 |
+
yield chunk
|
| 197 |
+
if isinstance(chunk, dict) and chunk.get("type") == "token":
|
| 198 |
+
full_reply += chunk["content"]
|
| 199 |
+
final_response = full_reply
|
| 200 |
+
|
| 201 |
+
memory.add_interaction(message, final_response)
|
| 202 |
+
self.store.record_message(session_id, "twin", final_response, thoughts=thoughts)
|
| 203 |
+
|
| 204 |
+
queue_chat_history_to_telegram(
|
| 205 |
+
session_id=session_id,
|
| 206 |
+
user_input=message,
|
| 207 |
+
assistant_response=final_response,
|
| 208 |
+
thoughts=thoughts,
|
| 209 |
+
tool_calls=executed_tools,
|
| 210 |
+
retrieved_chunks=retrieved_chunks,
|
| 211 |
+
github_data=github_data,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
# Unconditionally queue 100% automated chat alert for EVERY chat.
|
| 215 |
+
queue_automated_chat_alert(
|
| 216 |
+
session_id=session_id,
|
| 217 |
+
user_input=message,
|
| 218 |
+
assistant_response=final_response,
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
yield {
|
| 222 |
+
"type": "final",
|
| 223 |
+
"reply": final_response,
|
| 224 |
+
"thoughts": thoughts,
|
| 225 |
+
"session_id": session_id,
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
except Exception as err:
|
| 229 |
+
err_msg = f"API Error: {str(err)}"
|
| 230 |
+
yield {"type": "error", "content": err_msg}
|
| 231 |
+
|
| 232 |
+
async def trigger_ai_answer(self, session_id: str, extra_prompt: str = "") -> str:
|
| 233 |
+
"""Synthesize an AI answer after the /answer command from the real human."""
|
| 234 |
+
from backend.app.core.agent import init_agent
|
| 235 |
+
|
| 236 |
+
memory = self.store.get_or_create_memory(session_id)
|
| 237 |
+
main_llm, prompt, _, _ = init_agent()
|
| 238 |
+
|
| 239 |
+
session_msgs = self.store.get_history(session_id)
|
| 240 |
+
transcript_lines = []
|
| 241 |
+
last_user_input = ""
|
| 242 |
+
for m in session_msgs:
|
| 243 |
+
sender_label = (
|
| 244 |
+
"Visitor" if m.get("sender") == "user"
|
| 245 |
+
else "Real Arun Yadav (π¨βπ»)" if m.get("sender") == "human_arun"
|
| 246 |
+
else "Arun's AI Assistant"
|
| 247 |
+
)
|
| 248 |
+
transcript_lines.append(f"{sender_label}: {m.get('text')}")
|
| 249 |
+
if m.get("sender") == "user":
|
| 250 |
+
last_user_input = m.get("text")
|
| 251 |
+
|
| 252 |
+
full_transcript = "\n".join(transcript_lines)
|
| 253 |
+
|
| 254 |
+
system_notice = SystemMessage(content=(
|
| 255 |
+
f"π’ 3-WAY CHAT INSTRUCTION FOR AI TWIN:\n"
|
| 256 |
+
f"Real Arun Yadav issued the /answer command for you to respond to the visitor's question.\n"
|
| 257 |
+
f"Read the full 3-party conversation transcript below (Visitor, Real Arun Yadav, and AI Twin):\n\n"
|
| 258 |
+
f"--- FULL 3-WAY CHAT TRANSCRIPT ---\n{full_transcript}\n\n"
|
| 259 |
+
f"MANDATORY INSTRUCTIONS FOR AI TWIN:\n"
|
| 260 |
+
f"1. Synthesize all context from the visitor's question and Real Arun Yadav's live comments.\n"
|
| 261 |
+
f"2. Generate an accurate, helpful, and natural response for the visitor.\n"
|
| 262 |
+
f"3. Acknowledge Real Arun Yadav's live presence if relevant."
|
| 263 |
+
))
|
| 264 |
+
|
| 265 |
+
messages = prompt.format_messages(
|
| 266 |
+
running_summary=memory.running_summary,
|
| 267 |
+
chat_history=memory.get_messages(),
|
| 268 |
+
input=extra_prompt or last_user_input or "Please answer the visitor's question based on our 3-way conversation.",
|
| 269 |
+
agent_scratchpad=[],
|
| 270 |
+
)
|
| 271 |
+
messages.insert(1, system_notice)
|
| 272 |
+
|
| 273 |
+
ai_msg = await asyncio.to_thread(main_llm.invoke, messages)
|
| 274 |
+
final_reply = ai_msg.content.strip()
|
| 275 |
+
|
| 276 |
+
if final_reply:
|
| 277 |
+
self.store.record_message(
|
| 278 |
+
session_id, "twin", final_reply,
|
| 279 |
+
thoughts=["AI Twin triggered via /answer command."],
|
| 280 |
+
)
|
| 281 |
+
memory.add_interaction(last_user_input or "Visitor Query", final_reply)
|
| 282 |
+
|
| 283 |
+
return final_reply
|
| 284 |
+
|
| 285 |
+
async def _stream_tokens(self, llm, messages):
|
| 286 |
+
"""Yields {'type':'token', 'content': ...} chunks by streaming the LLM.
|
| 287 |
+
|
| 288 |
+
Mirrors the original implementation: the sync `llm.stream` generator is
|
| 289 |
+
consumed in place so tokens arrive incrementally over the wire.
|
| 290 |
+
"""
|
| 291 |
+
for chunk in llm.stream(messages):
|
| 292 |
+
if getattr(chunk, "content", None):
|
| 293 |
+
yield {"type": "token", "content": chunk.content}
|
| 294 |
+
await asyncio.sleep(0.005)
|
| 295 |
+
|
| 296 |
+
def sync_reply(
|
| 297 |
+
self,
|
| 298 |
+
session_id: str,
|
| 299 |
+
user_input: str,
|
| 300 |
+
llm: Any,
|
| 301 |
+
prompt: Any,
|
| 302 |
+
memory: Any,
|
| 303 |
+
tool_map: Optional[Dict[str, Any]] = None,
|
| 304 |
+
user_metadata: Optional[Dict[str, Any]] = None,
|
| 305 |
+
max_iterations: int = 3,
|
| 306 |
+
) -> str:
|
| 307 |
+
"""Blocking agent turn for non-streaming channels (Telegram bot).
|
| 308 |
+
|
| 309 |
+
Replicates the legacy bot loop exactly (3 iterations, tool errors
|
| 310 |
+
swallowed into the reply context, final fallback message).
|
| 311 |
+
"""
|
| 312 |
+
from backend.app.services.notification_service import (
|
| 313 |
+
queue_debug_event,
|
| 314 |
+
queue_maybe_notify_arun,
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
scratchpad: List[Any] = []
|
| 318 |
+
tool_map = tool_map or {}
|
| 319 |
+
|
| 320 |
+
try:
|
| 321 |
+
queue_debug_event(
|
| 322 |
+
"user_message",
|
| 323 |
+
user_input,
|
| 324 |
+
{"channel": "telegram", "chat_id": session_id, **(user_metadata or {})},
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
pre_escalation = run_pre_escalation(
|
| 328 |
+
user_input,
|
| 329 |
+
tool_map,
|
| 330 |
+
{"channel": "telegram", "chat_id": session_id, **(user_metadata or {})},
|
| 331 |
+
False,
|
| 332 |
+
)
|
| 333 |
+
if pre_escalation:
|
| 334 |
+
queue_debug_event(
|
| 335 |
+
"pre_escalation",
|
| 336 |
+
pre_escalation.get("result", ""),
|
| 337 |
+
{
|
| 338 |
+
"channel": "telegram",
|
| 339 |
+
"chat_id": session_id,
|
| 340 |
+
"category": pre_escalation.get("category"),
|
| 341 |
+
"reason": pre_escalation.get("reason"),
|
| 342 |
+
**(user_metadata or {}),
|
| 343 |
+
},
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
final_response = None
|
| 347 |
+
|
| 348 |
+
for _ in range(max_iterations):
|
| 349 |
+
messages = prompt.format_messages(
|
| 350 |
+
running_summary=memory.running_summary,
|
| 351 |
+
chat_history=memory.get_messages(),
|
| 352 |
+
input=user_input,
|
| 353 |
+
agent_scratchpad=scratchpad,
|
| 354 |
+
)
|
| 355 |
+
ai_msg = llm.invoke(messages)
|
| 356 |
+
|
| 357 |
+
if ai_msg.tool_calls:
|
| 358 |
+
scratchpad.append(ai_msg)
|
| 359 |
+
for tc in ai_msg.tool_calls:
|
| 360 |
+
tool_name = tc["name"]
|
| 361 |
+
tool_args = tc.get("args", {})
|
| 362 |
+
queue_debug_event(
|
| 363 |
+
"tool_call",
|
| 364 |
+
json.dumps(tool_args, ensure_ascii=False, indent=2, default=str),
|
| 365 |
+
{
|
| 366 |
+
"channel": "telegram",
|
| 367 |
+
"chat_id": session_id,
|
| 368 |
+
"tool_name": tool_name,
|
| 369 |
+
**(user_metadata or {}),
|
| 370 |
+
},
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
tool_func = tool_map.get(tool_name)
|
| 374 |
+
try:
|
| 375 |
+
result = tool_func.invoke(tool_args)
|
| 376 |
+
except Exception as e:
|
| 377 |
+
result = f"Tool error: {e}"
|
| 378 |
+
|
| 379 |
+
scratchpad.append({
|
| 380 |
+
"role": "tool",
|
| 381 |
+
"name": tool_name,
|
| 382 |
+
"tool_call_id": tc["id"],
|
| 383 |
+
"content": str(result)[:2000],
|
| 384 |
+
})
|
| 385 |
+
queue_debug_event(
|
| 386 |
+
"tool_result",
|
| 387 |
+
str(result),
|
| 388 |
+
{
|
| 389 |
+
"channel": "telegram",
|
| 390 |
+
"chat_id": session_id,
|
| 391 |
+
"tool_name": tool_name,
|
| 392 |
+
**(user_metadata or {}),
|
| 393 |
+
},
|
| 394 |
+
)
|
| 395 |
+
else:
|
| 396 |
+
final_response = ai_msg.content
|
| 397 |
+
break
|
| 398 |
+
|
| 399 |
+
if not final_response:
|
| 400 |
+
final_response = "I ran into an issue internally. Please try again."
|
| 401 |
+
|
| 402 |
+
queue_debug_event(
|
| 403 |
+
"assistant_reply",
|
| 404 |
+
final_response,
|
| 405 |
+
{"channel": "telegram", "chat_id": session_id, **(user_metadata or {})},
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
queue_maybe_notify_arun(
|
| 409 |
+
user_input=user_input,
|
| 410 |
+
final_response=final_response,
|
| 411 |
+
scratchpad=scratchpad,
|
| 412 |
+
tool_map=tool_map,
|
| 413 |
+
user_metadata={"channel": "telegram", "chat_id": session_id, **(user_metadata or {})},
|
| 414 |
+
pre_notified=bool(pre_escalation and pre_escalation.get("handled")),
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
memory.add_interaction(user_input, final_response)
|
| 418 |
+
return final_response
|
| 419 |
+
except Exception as e:
|
| 420 |
+
queue_debug_event(
|
| 421 |
+
"error",
|
| 422 |
+
str(e),
|
| 423 |
+
{"channel": "telegram", "chat_id": session_id, **(user_metadata or {})},
|
| 424 |
+
)
|
| 425 |
+
raise
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
agent_runner = AgentRunner(agent_factory=None)
|
backend/app/services/auth_service.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin authentication for the 3-way live human chat takeover.
|
| 2 |
+
|
| 3 |
+
Real Arun logs in through a signed admin token embedded in the 1-Click Join
|
| 4 |
+
link generated by Telegram alerts. The token is derived from the session id
|
| 5 |
+
plus a shared secret, keeping the webhook/join flow stateless.
|
| 6 |
+
"""
|
| 7 |
+
import hashlib
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "aruncore_secret_key_2026")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def generate_admin_token(session_id: str) -> str:
|
| 14 |
+
raw = f"{session_id}:{ADMIN_SECRET_KEY}".encode("utf-8")
|
| 15 |
+
return hashlib.sha256(raw).hexdigest()[:16]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def verify_admin_token(session_id: str, token: str) -> bool:
|
| 19 |
+
if not session_id or not token:
|
| 20 |
+
return False
|
| 21 |
+
expected = generate_admin_token(session_id)
|
| 22 |
+
return token.strip() == expected
|
backend/app/services/background.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single-responsibility background task queue.
|
| 2 |
+
|
| 3 |
+
The ArunCore backend performs several fire-and-forget jobs (Telegram delivery,
|
| 4 |
+
chat history logging, debug event logging, re-ingestion triggering). All of
|
| 5 |
+
that work is submitted here instead of being swallowed by the request /
|
| 6 |
+
agent loop, so slow network calls never block chat streaming.
|
| 7 |
+
"""
|
| 8 |
+
import queue
|
| 9 |
+
import threading
|
| 10 |
+
from typing import Any, Callable
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
_task_queue: "queue.Queue[Any]" = queue.Queue()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _background_worker() -> None:
|
| 17 |
+
import urllib3
|
| 18 |
+
|
| 19 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
| 20 |
+
|
| 21 |
+
while True:
|
| 22 |
+
try:
|
| 23 |
+
task = _task_queue.get()
|
| 24 |
+
if task is None:
|
| 25 |
+
break
|
| 26 |
+
func, args, kwargs = task
|
| 27 |
+
try:
|
| 28 |
+
func(*args, **kwargs)
|
| 29 |
+
except Exception as e:
|
| 30 |
+
print(f"[BACKGROUND WORKER ERROR] Failed in {getattr(func, '__name__', func)}: {e}")
|
| 31 |
+
finally:
|
| 32 |
+
_task_queue.task_done()
|
| 33 |
+
except Exception as outer_e:
|
| 34 |
+
print(f"[BACKGROUND WORKER FATAL] Queue fetch failed: {outer_e}")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_thread = threading.Thread(target=_background_worker, daemon=True, name="aruncore-background")
|
| 38 |
+
_thread.start()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def submit_background_task(name: str, func: Callable, *args: Any, **kwargs: Any) -> bool:
|
| 42 |
+
"""Enqueue a callable to run on the shared background worker thread."""
|
| 43 |
+
try:
|
| 44 |
+
_task_queue.put((func, args, kwargs))
|
| 45 |
+
print(f"[BACKGROUND] {name}: Task queued.")
|
| 46 |
+
return True
|
| 47 |
+
except Exception as e:
|
| 48 |
+
print(f"[BACKGROUND ERROR] Failed to queue {name}: {e}")
|
| 49 |
+
return False
|
backend/app/services/knowledge_service.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Knowledge retrieval layer of the ArunCore digital twin.
|
| 2 |
+
|
| 3 |
+
Owns every read against the on-disk knowledge base:
|
| 4 |
+
|
| 5 |
+
* `data/github/<repo>/README.md` historical READMEs
|
| 6 |
+
* `data/linkedin/posts.md` scraped posts
|
| 7 |
+
* `data/static/*.md` profile, rules of engagement
|
| 8 |
+
* `data/raw/personal_background.md`
|
| 9 |
+
* `data/raw/unknown_questions.json` verified Q&A pairs
|
| 10 |
+
|
| 11 |
+
It also handles live GitHub lookups and persists newly learned Q&A pairs
|
| 12 |
+
(the active-learning feedback loop).
|
| 13 |
+
"""
|
| 14 |
+
import os
|
| 15 |
+
import re
|
| 16 |
+
import json
|
| 17 |
+
import datetime
|
| 18 |
+
import subprocess
|
| 19 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 20 |
+
|
| 21 |
+
import requests
|
| 22 |
+
|
| 23 |
+
from backend.app.services.notification_service import safe_truncate, schedule_notify_arun
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _project_root() -> str:
|
| 27 |
+
"""profile/ (the repository root holding `data/` and `backend/`)."""
|
| 28 |
+
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ------------------------------------------------------------------ #
|
| 32 |
+
# Chunking + relevance scoring helpers #
|
| 33 |
+
# ------------------------------------------------------------------ #
|
| 34 |
+
_QUERY_STOP_WORDS = {
|
| 35 |
+
"how", "does", "the", "a", "an", "is", "for", "to", "of", "with", "work",
|
| 36 |
+
"what", "tell", "me", "about", "can", "you", "who", "where", "why", "arun",
|
| 37 |
+
"and", "or", "that", "this", "it", "in", "on", "at", "your", "my", "i", "we",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
_BOILERPLATE_LINE_RE = re.compile(
|
| 41 |
+
r"^(>\s*\*\*GitHub Repository:|\*\*Primary Language:|>\s*\*\*Primary Language:|"
|
| 42 |
+
r">\s*\*\*Description:|>\s*\*\*Stars:|\*\*GitHub|#\s+[\w_-]+\s*$)"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _query_terms(cleaned_query: str) -> List[str]:
|
| 47 |
+
"""Significant query words (stopwords removed, len>2)."""
|
| 48 |
+
|
| 49 |
+
def _tok(w: str) -> str:
|
| 50 |
+
return w.strip("?.,!;:'\"()[]{}")
|
| 51 |
+
|
| 52 |
+
return [
|
| 53 |
+
_tok(w) for w in cleaned_query.split()
|
| 54 |
+
if _tok(w) and _tok(w).lower() not in _QUERY_STOP_WORDS and len(_tok(w)) > 2
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _relevance(score: int, alpha: float = 1.0) -> float:
|
| 59 |
+
"""Normalize a raw hit count into a 0..Λ2 ordering score."""
|
| 60 |
+
return alpha * (1.0 + min(score, 6))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _term_hits(text: str, terms: List[str]) -> int:
|
| 64 |
+
"""Count word-boundary, case-insensitive term occurrences."""
|
| 65 |
+
if not terms or not text:
|
| 66 |
+
return 0
|
| 67 |
+
lowered = text.lower()
|
| 68 |
+
hits = 0
|
| 69 |
+
for term in terms:
|
| 70 |
+
hits += len(re.findall(rf"(?<![a-z0-9]){re.escape(term)}(?![a-z0-9])", lowered))
|
| 71 |
+
return hits
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _term_score(heading: str, body: str, terms: List[str]) -> int:
|
| 75 |
+
"""Weighted relevance: heading hits count double body hits."""
|
| 76 |
+
if not terms:
|
| 77 |
+
return 0
|
| 78 |
+
return 2 * _term_hits(heading, terms) + _term_hits(body, terms)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _score_term_streak(text: str, terms: List[str]) -> int:
|
| 82 |
+
"""Consecutive-significant-term density bonus (e.g. 'clinical reasoning tutor')."""
|
| 83 |
+
if not terms or not text:
|
| 84 |
+
return 0
|
| 85 |
+
lowered = text.lower()
|
| 86 |
+
streak = 0
|
| 87 |
+
best = 0
|
| 88 |
+
words = re.findall(r"[a-z0-9]+", lowered)
|
| 89 |
+
term_set_low = {t.lower() for t in terms}
|
| 90 |
+
for w in words:
|
| 91 |
+
if w in term_set_low:
|
| 92 |
+
streak += 1
|
| 93 |
+
best = max(best, streak)
|
| 94 |
+
else:
|
| 95 |
+
streak = 0
|
| 96 |
+
return 2 * best + _term_hits(text, terms)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _clean_readme(content: str) -> str:
|
| 100 |
+
"""Strip YAML frontmatter + GitHub API boilerplate blockquote lines."""
|
| 101 |
+
if content.startswith("---"):
|
| 102 |
+
parts = content.split("---", 2)
|
| 103 |
+
if len(parts) >= 3:
|
| 104 |
+
content = parts[2]
|
| 105 |
+
out = []
|
| 106 |
+
for line in content.splitlines():
|
| 107 |
+
stripped = line.strip()
|
| 108 |
+
if _BOILERPLATE_LINE_RE.match(stripped):
|
| 109 |
+
continue
|
| 110 |
+
if stripped.startswith("> **GitHub Repository:") or stripped.startswith("> **Primary Language:") or stripped.startswith(">-"):
|
| 111 |
+
continue
|
| 112 |
+
out.append(line)
|
| 113 |
+
return "\n".join(out)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _score_readme_sections(content: str, terms: List[str], max_chars: int = 1800) -> List[Tuple[str, str, int]]:
|
| 117 |
+
"""Split a project README into heading-level chunks, scored by query hits.
|
| 118 |
+
|
| 119 |
+
Returns (heading, body, score) triples sorted best-first (score desc).
|
| 120 |
+
"""
|
| 121 |
+
cleaned = _clean_readme(content)
|
| 122 |
+
sections: List[Tuple[str, str]] = []
|
| 123 |
+
cur_heading: Optional[str] = None
|
| 124 |
+
cur_body: List[str] = []
|
| 125 |
+
|
| 126 |
+
def _flush() -> None:
|
| 127 |
+
nonlocal cur_body
|
| 128 |
+
body = "\n".join(cur_body).strip()
|
| 129 |
+
cur_body = []
|
| 130 |
+
if body:
|
| 131 |
+
sections.append((cur_heading or "Overview", body))
|
| 132 |
+
|
| 133 |
+
for line in cleaned.splitlines():
|
| 134 |
+
if re.match(r"^#{1,4} ", line):
|
| 135 |
+
_flush()
|
| 136 |
+
cur_heading = re.sub(r"^#{1,4} ", "", line).strip()
|
| 137 |
+
else:
|
| 138 |
+
cur_body.append(line)
|
| 139 |
+
_flush()
|
| 140 |
+
|
| 141 |
+
scored = []
|
| 142 |
+
for heading, body in sections:
|
| 143 |
+
if not body:
|
| 144 |
+
continue
|
| 145 |
+
score = _term_score(heading, body, terms)
|
| 146 |
+
if score > 0:
|
| 147 |
+
truncated = body[:max_chars].rstrip()
|
| 148 |
+
if len(body) > max_chars:
|
| 149 |
+
truncated += "..."
|
| 150 |
+
scored.append((heading, truncated, score))
|
| 151 |
+
scored.sort(key=lambda x: x[2], reverse=True)
|
| 152 |
+
return scored
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _score_profile_sections(content: str, terms: List[str], max_chars: int = 1200) -> List[Tuple[str, str, int]]:
|
| 156 |
+
"""Split a profile / background doc into heading-level chunks, scored."""
|
| 157 |
+
sections: List[Tuple[str, str]] = []
|
| 158 |
+
current_heading = ""
|
| 159 |
+
cur: List[str] = []
|
| 160 |
+
|
| 161 |
+
def _flush() -> None:
|
| 162 |
+
nonlocal cur
|
| 163 |
+
body = "\n".join(cur).strip()
|
| 164 |
+
cur = []
|
| 165 |
+
if body:
|
| 166 |
+
sections.append((current_heading, body))
|
| 167 |
+
|
| 168 |
+
for line in content.splitlines():
|
| 169 |
+
if re.match(r"^#{1,3} ", line):
|
| 170 |
+
_flush()
|
| 171 |
+
current_heading = line.lstrip("# ").strip()
|
| 172 |
+
elif line.strip() == "---":
|
| 173 |
+
_flush()
|
| 174 |
+
else:
|
| 175 |
+
cur.append(line)
|
| 176 |
+
_flush()
|
| 177 |
+
|
| 178 |
+
scored = []
|
| 179 |
+
for heading, body in sections:
|
| 180 |
+
if not body.strip():
|
| 181 |
+
continue
|
| 182 |
+
score = _score_term_streak(heading + "\n" + body, terms)
|
| 183 |
+
if score > 0:
|
| 184 |
+
truncated = body[:max_chars].rstrip()
|
| 185 |
+
if len(body) > max_chars:
|
| 186 |
+
truncated += "..."
|
| 187 |
+
scored.append((f"### {heading}", truncated, score))
|
| 188 |
+
scored.sort(key=lambda x: x[2], reverse=True)
|
| 189 |
+
return scored
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _clean_linkedin_posts(posts_text: str) -> List[str]:
|
| 193 |
+
"""Strip YAML/meta boilerplate and without params, return bare post bodies."""
|
| 194 |
+
if posts_text.startswith("---"):
|
| 195 |
+
parts = posts_text.split("---", 2)
|
| 196 |
+
if len(parts) >= 3:
|
| 197 |
+
posts_text = parts[2]
|
| 198 |
+
posts = []
|
| 199 |
+
for block in re.split(r"(?m)^## ", posts_text):
|
| 200 |
+
block = block.strip()
|
| 201 |
+
if not block:
|
| 202 |
+
continue
|
| 203 |
+
lines: List[str] = []
|
| 204 |
+
for line in block.splitlines():
|
| 205 |
+
ls = line.strip()
|
| 206 |
+
if ls.startswith("**Date:") or ls.startswith("**Post Title") or ls.startswith("> **Link:"):
|
| 207 |
+
continue
|
| 208 |
+
lines.append(line)
|
| 209 |
+
body = "\n".join(lines).strip()
|
| 210 |
+
if body:
|
| 211 |
+
posts.append("# " + body)
|
| 212 |
+
return posts
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class KnowledgeService:
|
| 216 |
+
"""Single responsibility: every read/write against Arun's knowledge data."""
|
| 217 |
+
|
| 218 |
+
def __init__(self, root_dir: Optional[str] = None):
|
| 219 |
+
self.root_dir = root_dir or _project_root()
|
| 220 |
+
self.data_dir = os.path.join(self.root_dir, "data")
|
| 221 |
+
|
| 222 |
+
# ------------------------------------------------------------------ #
|
| 223 |
+
# Static persona documents (2-tuple used by the legacy API layer) #
|
| 224 |
+
# ------------------------------------------------------------------ #
|
| 225 |
+
def load_static_profile_and_rules(self) -> Tuple[str, str]:
|
| 226 |
+
profile_content = ""
|
| 227 |
+
rules_content = ""
|
| 228 |
+
|
| 229 |
+
profile_path = os.path.join(self.data_dir, "static", "public_profile.md")
|
| 230 |
+
rules_path = os.path.join(self.data_dir, "static", "rules_of_engagement.md")
|
| 231 |
+
|
| 232 |
+
if os.path.exists(profile_path):
|
| 233 |
+
with open(profile_path, "r", encoding="utf-8") as f:
|
| 234 |
+
profile_content = f.read()
|
| 235 |
+
|
| 236 |
+
if os.path.exists(rules_path):
|
| 237 |
+
with open(rules_path, "r", encoding="utf-8") as f:
|
| 238 |
+
rules_content = f.read()
|
| 239 |
+
|
| 240 |
+
return profile_content, rules_content
|
| 241 |
+
|
| 242 |
+
# ------------------------------------------------------------------ #
|
| 243 |
+
# GitHub live data
|
| 244 |
+
# ------------------------------------------------------------------ #
|
| 245 |
+
def fetch_live_github(self, username: str = "neural-arun") -> str:
|
| 246 |
+
try:
|
| 247 |
+
res = requests.get(
|
| 248 |
+
f"https://api.github.com/users/{username}/repos?sort=updated&per_page=10",
|
| 249 |
+
timeout=5,
|
| 250 |
+
)
|
| 251 |
+
if res.status_code == 200:
|
| 252 |
+
repos = res.json()
|
| 253 |
+
lines = [
|
| 254 |
+
f"β’ [{r['name']}]({r['html_url']}) - {r.get('description', 'No description')} (Updated: {r['updated_at'][:10]})"
|
| 255 |
+
for r in repos
|
| 256 |
+
]
|
| 257 |
+
return "### Live GitHub Repositories:\n" + "\n".join(lines)
|
| 258 |
+
except Exception as e:
|
| 259 |
+
return f"GitHub fetch error: {e}"
|
| 260 |
+
return "Could not fetch GitHub data."
|
| 261 |
+
|
| 262 |
+
# ------------------------------------------------------------------ #
|
| 263 |
+
# Free-form knowledge search (previously search_arun_knowledge) #
|
| 264 |
+
# ------------------------------------------------------------------ #
|
| 265 |
+
def search(self, query: str) -> str:
|
| 266 |
+
"""Retrieve a compact, relevance-ranked set of knowledge chunks.
|
| 267 |
+
|
| 268 |
+
Every source (project READMEs, LinkedIn posts, static profile docs,
|
| 269 |
+
verified Q&A pairs) is split into heading-level chunks, the YAML/GitHub
|
| 270 |
+
boilerplate is stripped, each chunk is scored against the query's
|
| 271 |
+
significant terms, and only the best-scoring chunks are returned. The
|
| 272 |
+
tool output is therefore a bounded set of *clean sections* instead of
|
| 273 |
+
whole raw files.
|
| 274 |
+
"""
|
| 275 |
+
data_dir = self.data_dir
|
| 276 |
+
cleaned_query = (query or "").lower().replace("-", " ").replace("_", " ")
|
| 277 |
+
query_terms = _query_terms(cleaned_query)
|
| 278 |
+
|
| 279 |
+
project_aliases = {
|
| 280 |
+
"med_coach": ["med_coach", "medcoach", "med coach", "medical tutor", "clinical reasoning", "clinical tutor"],
|
| 281 |
+
"legal_RAG_system": ["legal_rag_system", "legal rag", "legal", "ipc", "indian penal code", "chunking"],
|
| 282 |
+
"neet-bot": ["neet-bot", "neet bot", "neet 2027", "cbt simulator", "ncert", "mcq"],
|
| 283 |
+
"real_state_listing_scraper": ["real_state_listing_scraper", "99acres", "cloudflare", "scraper", "real estate"],
|
| 284 |
+
"ArunCore": ["aruncore", "profile", "assistant", "vector database", "reranking", "fastapi"],
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
matched_folders = []
|
| 288 |
+
for folder_name, synonyms in project_aliases.items():
|
| 289 |
+
if any(syn in cleaned_query for syn in synonyms):
|
| 290 |
+
matched_folders.append(folder_name)
|
| 291 |
+
|
| 292 |
+
results: List[Tuple[float, str]] = []
|
| 293 |
+
|
| 294 |
+
# 1. PROJECT READMES -> section-level chunks, ranked by relevance.
|
| 295 |
+
for folder in matched_folders:
|
| 296 |
+
target_readme = os.path.join(data_dir, "github", folder, "README.md")
|
| 297 |
+
if os.path.exists(target_readme):
|
| 298 |
+
with open(target_readme, "r", encoding="utf-8") as f:
|
| 299 |
+
content = f.read()
|
| 300 |
+
for heading, body, score in _score_readme_sections(content, query_terms):
|
| 301 |
+
if score > 0 and body:
|
| 302 |
+
block = f"## {heading}\n{body.strip()}"
|
| 303 |
+
results.append((_relevance(score), f"--- Project README ({folder}) ---\n{block}"))
|
| 304 |
+
|
| 305 |
+
stop_words = {"how", "does", "the", "a", "an", "is", "for", "to", "of", "with", "work", "what", "tell", "me", "about", "can", "you", "who", "where", "why", "arun"}
|
| 306 |
+
significant_words = [w.strip("?,.!") for w in cleaned_query.split() if w.strip("?,.!") not in stop_words and len(w) > 2]
|
| 307 |
+
|
| 308 |
+
# 2. LINKEDIN POSTS: strip meta boilerplate, chunk per post, rank.
|
| 309 |
+
posts_path = os.path.join(data_dir, "linkedin", "posts.md")
|
| 310 |
+
if os.path.exists(posts_path):
|
| 311 |
+
with open(posts_path, "r", encoding="utf-8") as f:
|
| 312 |
+
posts_text = f.read()
|
| 313 |
+
for post in _clean_linkedin_posts(posts_text):
|
| 314 |
+
body = post.strip()
|
| 315 |
+
if not body:
|
| 316 |
+
continue
|
| 317 |
+
score = sum(1 for w in significant_words if w.lower() in body.lower())
|
| 318 |
+
if score > 0 and query_terms:
|
| 319 |
+
results.append((_relevance(score, 0.8), f"--- Relevant LinkedIn Post ---\n{body[:2200]}"))
|
| 320 |
+
|
| 321 |
+
# 3. CORE PROFILE & BACKGROUND DOCS: heading-level chunks, ranked.
|
| 322 |
+
other_static_files = [
|
| 323 |
+
os.path.join(data_dir, "static", "public_profile.md"),
|
| 324 |
+
os.path.join(data_dir, "static", "rules_of_engagement.md"),
|
| 325 |
+
os.path.join(data_dir, "raw", "personal_background.md"),
|
| 326 |
+
]
|
| 327 |
+
static_hits: List[Tuple[str, str, float]] = []
|
| 328 |
+
for s_file in other_static_files:
|
| 329 |
+
if os.path.exists(s_file):
|
| 330 |
+
with open(s_file, "r", encoding="utf-8") as f:
|
| 331 |
+
content = f.read()
|
| 332 |
+
for heading, chunk, score in _score_profile_sections(content, query_terms):
|
| 333 |
+
if score > 0:
|
| 334 |
+
block = f"{heading}\n{chunk.strip()}"
|
| 335 |
+
if len(block.strip()) > 80:
|
| 336 |
+
static_hits.append((_relevance(score, 0.9), f"--- Document Insight ({os.path.basename(s_file)}) ---\n{block.strip()}"))
|
| 337 |
+
results.extend(static_hits)
|
| 338 |
+
|
| 339 |
+
# Domain fallback: "stack/tech/language/framework/tools" queries that
|
| 340 |
+
# the lexical matcher missed still deserve the Technical Stack section.
|
| 341 |
+
stack_terms = {"stack", "tech", "technology", "technologies", "language", "framework", "tool", "tools", "languages"}
|
| 342 |
+
if static_hits and not any(
|
| 343 |
+
"Technical Stack" in block for _, block in static_hits
|
| 344 |
+
) and any(t in stack_terms for t in query_terms):
|
| 345 |
+
profile_path = os.path.join(data_dir, "static", "public_profile.md")
|
| 346 |
+
if os.path.exists(profile_path):
|
| 347 |
+
with open(profile_path, "r", encoding="utf-8") as f:
|
| 348 |
+
profile_content = f.read()
|
| 349 |
+
for heading, chunk, score in _score_profile_sections(profile_content, stack_terms):
|
| 350 |
+
if "Technical Stack" in heading:
|
| 351 |
+
block = f"{heading}\n{chunk.strip()}"
|
| 352 |
+
results.append((2.0, f"--- Document Insight (public_profile.md) ---\n{block}"))
|
| 353 |
+
|
| 354 |
+
# 4. VERIFIED Q&A PAIRS from the active-learning store.
|
| 355 |
+
unknown_questions_path = os.path.join(data_dir, "raw", "unknown_questions.json")
|
| 356 |
+
if os.path.exists(unknown_questions_path):
|
| 357 |
+
try:
|
| 358 |
+
with open(unknown_questions_path, "r", encoding="utf-8") as f:
|
| 359 |
+
uq_data = json.load(f)
|
| 360 |
+
if isinstance(uq_data, list):
|
| 361 |
+
for item in uq_data:
|
| 362 |
+
q_item = item.get("question", "")
|
| 363 |
+
a_item = item.get("answer", "")
|
| 364 |
+
if any(w in q_item.lower() or w in a_item.lower() for w in significant_words):
|
| 365 |
+
results.append((0.6, f"--- Verified Q&A Pair ---\nQuestion: {q_item}\nAnswer: {a_item}"))
|
| 366 |
+
except Exception as e:
|
| 367 |
+
print(f"[UNKNOWN QUESTIONS READ ERROR] {e}")
|
| 368 |
+
|
| 369 |
+
# Sort by relevance score, dedupe, cap the total payload.
|
| 370 |
+
results.sort(key=lambda x: x[0], reverse=True)
|
| 371 |
+
unique_results = []
|
| 372 |
+
seen = set()
|
| 373 |
+
payload_len = 0
|
| 374 |
+
for _, block in results:
|
| 375 |
+
block = block.strip()
|
| 376 |
+
if block in seen:
|
| 377 |
+
continue
|
| 378 |
+
if len(block) > 3000:
|
| 379 |
+
block = block[:3000].rstrip() + "..."
|
| 380 |
+
if payload_len + len(block) > 9000:
|
| 381 |
+
break
|
| 382 |
+
unique_results.append(block)
|
| 383 |
+
seen.add(block)
|
| 384 |
+
payload_len += len(block) + 2
|
| 385 |
+
|
| 386 |
+
if unique_results:
|
| 387 |
+
return "\n\n".join(unique_results)
|
| 388 |
+
|
| 389 |
+
# Run out of chunks -> auto-trigger notification
|
| 390 |
+
schedule_notify_arun(
|
| 391 |
+
"UNKNOWN_QUESTION",
|
| 392 |
+
f"Unknown Question (No KB Match): {query}",
|
| 393 |
+
)
|
| 394 |
+
return (
|
| 395 |
+
"No exact match found in knowledge base. Auto-triggered UNKNOWN_QUESTION alert to Arun's phone. "
|
| 396 |
+
"YOU MUST CALL notify_arun AND ASK THE USER FOR THEIR CONTACT INFO (Name, Email, Phone/WhatsApp)."
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
# ------------------------------------------------------------------ #
|
| 400 |
+
# Active learning: persist a verified Q&A and re-ingest into memory #
|
| 401 |
+
# ------------------------------------------------------------------ #
|
| 402 |
+
def save_verified_answer(self, question: str, answer: str) -> str:
|
| 403 |
+
"""Writes an answered unknown question into data/raw/unknown_questions.json and triggers vector DB re-ingestion."""
|
| 404 |
+
target_file = os.path.join(self.data_dir, "raw", "unknown_questions.json")
|
| 405 |
+
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
| 406 |
+
|
| 407 |
+
now_iso = datetime.datetime.utcnow().isoformat() + "Z"
|
| 408 |
+
|
| 409 |
+
entry = {
|
| 410 |
+
"question": question.strip(),
|
| 411 |
+
"answer": answer.strip(),
|
| 412 |
+
"timestamp": now_iso,
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
existing = []
|
| 416 |
+
if os.path.exists(target_file):
|
| 417 |
+
try:
|
| 418 |
+
with open(target_file, "r", encoding="utf-8") as f:
|
| 419 |
+
existing = json.load(f)
|
| 420 |
+
if not isinstance(existing, list):
|
| 421 |
+
existing = []
|
| 422 |
+
except Exception:
|
| 423 |
+
existing = []
|
| 424 |
+
|
| 425 |
+
updated = False
|
| 426 |
+
for item in existing:
|
| 427 |
+
if item.get("question", "").lower() == question.strip().lower():
|
| 428 |
+
item["answer"] = answer.strip()
|
| 429 |
+
item["timestamp"] = now_iso
|
| 430 |
+
updated = True
|
| 431 |
+
break
|
| 432 |
+
|
| 433 |
+
if not updated:
|
| 434 |
+
existing.append(entry)
|
| 435 |
+
|
| 436 |
+
with open(target_file, "w", encoding="utf-8") as f:
|
| 437 |
+
json.dump(existing, f, indent=2)
|
| 438 |
+
|
| 439 |
+
ingest_script = os.path.join(self.root_dir, "backend", "app", "core", "ingest.py")
|
| 440 |
+
try:
|
| 441 |
+
subprocess.Popen(["python3", ingest_script])
|
| 442 |
+
except Exception as e:
|
| 443 |
+
print(f"[REINGEST ERROR] {e}")
|
| 444 |
+
|
| 445 |
+
return "SUCCESS: Saved to unknown_questions.json and ingested into memory."
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
knowledge_service = KnowledgeService()
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def load_static_context() -> Tuple[str, str]:
|
| 452 |
+
"""Backward-compatible 2-tuple reader (profile, rules)."""
|
| 453 |
+
return knowledge_service.load_static_profile_and_rules()
|
backend/app/services/memory_manager.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rolling conversation memory with summary-compression.
|
| 2 |
+
|
| 3 |
+
A single session's chat lives in a shrinking window. Every N turns the oldest
|
| 4 |
+
messages are folded into a compact running summary by the summary LLM, so long
|
| 5 |
+
conversations keep full context without blowing the token budget.
|
| 6 |
+
"""
|
| 7 |
+
from typing import Any, List, Optional
|
| 8 |
+
|
| 9 |
+
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class RollingMemory:
|
| 13 |
+
"""Stateful per-session memory used across API and Telegram chat loops."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, summary_llm: Any, max_turns: int = 4):
|
| 16 |
+
self.summary_llm = summary_llm
|
| 17 |
+
self.max_turns = max_turns
|
| 18 |
+
self.history: List[BaseMessage] = []
|
| 19 |
+
self.running_summary: str = "No prior summary. This is the start of the conversation."
|
| 20 |
+
self.invocation_count = 0
|
| 21 |
+
|
| 22 |
+
def add_interaction(self, human_text: str, ai_text: str):
|
| 23 |
+
self.history.append(HumanMessage(content=human_text))
|
| 24 |
+
self.history.append(AIMessage(content=ai_text))
|
| 25 |
+
self.invocation_count += 1
|
| 26 |
+
|
| 27 |
+
if self.invocation_count >= self.max_turns:
|
| 28 |
+
self._summarize_and_prune()
|
| 29 |
+
|
| 30 |
+
def _summarize_and_prune(self):
|
| 31 |
+
print("\n[SYSTEM] Triggering background summarization...")
|
| 32 |
+
messages_to_summarize = self.history[:-4]
|
| 33 |
+
|
| 34 |
+
if not messages_to_summarize:
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
chat_transcript = "\n".join(
|
| 38 |
+
[f"{'User' if isinstance(m, HumanMessage) else 'Arun Assistant'}: {m.content}" for m in messages_to_summarize]
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
prompt = (
|
| 42 |
+
"You are an internal memory compression engine for Arun's AI Assistant.\n"
|
| 43 |
+
"Merge the existing summary with the new transcript. Preserve technical context, names, project mentions, user goals, and important decisions. "
|
| 44 |
+
"Keep it concise and stable. Return no more than 5 sentences.\n\n"
|
| 45 |
+
f"--- EXISTING SUMMARY ---\n{self.running_summary}\n\n"
|
| 46 |
+
f"--- NEW CHAT TO MERGE ---\n{chat_transcript}"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
res = self.summary_llm.invoke([SystemMessage(content=prompt)])
|
| 51 |
+
self.running_summary = res.content.strip()
|
| 52 |
+
self.history = self.history[-4:]
|
| 53 |
+
self.invocation_count = len(self.history) // 2
|
| 54 |
+
print(f"[SYSTEM] Memory compressed. New summary: {self.running_summary[:120]}...")
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f"[SYSTEM ERROR] Failed to summarize memory: {e}")
|
| 57 |
+
|
| 58 |
+
def get_messages(self) -> List[BaseMessage]:
|
| 59 |
+
return self.history
|
| 60 |
+
|
| 61 |
+
def clear(self):
|
| 62 |
+
self.history.clear()
|
| 63 |
+
self.running_summary = "No prior summary. This is the start of the conversation."
|
| 64 |
+
self.invocation_count = 0
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# Backwards-compatible alias: the decoupled API exposes this class as
|
| 68 |
+
# `MemoryManager` while the agent layer keeps the historical `RollingMemory` name.
|
| 69 |
+
MemoryManager = RollingMemory
|
backend/app/services/notification_service.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Telegram notification & alert delivery.
|
| 2 |
+
|
| 3 |
+
All outgoing Telegram traffic is consolidated here: chat history logging,
|
| 4 |
+
debug events, per-message live chat alerts with the 1-Click Join link, and
|
| 5 |
+
the category-based notify_arun escalation system. Every call is fire-and-forget
|
| 6 |
+
via the shared background queue so the chat loop never blocks on the network.
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
import json
|
| 10 |
+
import time
|
| 11 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 12 |
+
|
| 13 |
+
from backend.app.services.background import submit_background_task
|
| 14 |
+
from backend.app.services.auth_service import generate_admin_token
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _is_truthy_env(val: Optional[str], default: bool = True) -> bool:
|
| 18 |
+
if val is None:
|
| 19 |
+
return default
|
| 20 |
+
return val.strip().lower() in ("1", "true", "yes", "on")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _is_telegram_debug_enabled() -> bool:
|
| 24 |
+
return _is_truthy_env(os.getenv("TELEGRAM_DEBUG_ENABLED"), default=True)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _get_telegram_target(debug: bool = False, alert: bool = False) -> Tuple[Optional[str], Optional[str]]:
|
| 28 |
+
if alert:
|
| 29 |
+
token = os.getenv("TELEGRAM_ALERT_BOT_TOKEN") or os.getenv("TELEGRAM_BOT_TOKEN")
|
| 30 |
+
chat_id = os.getenv("TELEGRAM_ALERT_CHAT_ID") or os.getenv("TELEGRAM_CHAT_ID")
|
| 31 |
+
return token, chat_id
|
| 32 |
+
if debug:
|
| 33 |
+
token = os.getenv("TELEGRAM_DEBUG_BOT_TOKEN") or os.getenv("TELEGRAM_BOT_TOKEN")
|
| 34 |
+
chat_id = os.getenv("TELEGRAM_DEBUG_CHAT_ID") or os.getenv("TELEGRAM_CHAT_ID")
|
| 35 |
+
return token, chat_id
|
| 36 |
+
|
| 37 |
+
return os.getenv("TELEGRAM_BOT_TOKEN"), os.getenv("TELEGRAM_CHAT_ID")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def safe_truncate(text: str, limit: int = 1500) -> str:
|
| 41 |
+
cleaned = (text or "").strip()
|
| 42 |
+
if len(cleaned) <= limit:
|
| 43 |
+
return cleaned
|
| 44 |
+
return cleaned[:limit] + "..."
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _escape_html(text: str) -> str:
|
| 48 |
+
return (
|
| 49 |
+
(text or "")
|
| 50 |
+
.replace("&", "&")
|
| 51 |
+
.replace("<", "<")
|
| 52 |
+
.replace(">", ">")
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _chunk_text(text: str, limit: int = 2200) -> List[str]:
|
| 57 |
+
cleaned = (text or "").strip() or "(empty)"
|
| 58 |
+
parts: List[str] = []
|
| 59 |
+
remaining = cleaned
|
| 60 |
+
|
| 61 |
+
while len(remaining) > limit:
|
| 62 |
+
split_at = remaining.rfind("\n", 0, limit)
|
| 63 |
+
if split_at < int(limit * 0.5):
|
| 64 |
+
split_at = remaining.rfind(" ", 0, limit)
|
| 65 |
+
if split_at <= 0:
|
| 66 |
+
split_at = limit
|
| 67 |
+
|
| 68 |
+
parts.append(remaining[:split_at].strip())
|
| 69 |
+
remaining = remaining[split_at:].lstrip()
|
| 70 |
+
|
| 71 |
+
if remaining:
|
| 72 |
+
parts.append(remaining)
|
| 73 |
+
|
| 74 |
+
return parts or ["(empty)"]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
TELEGRAM_DELIVERY_LOGS: List[Dict[str, Any]] = []
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _record_telegram_log(label: str, status: str, detail: str) -> None:
|
| 81 |
+
entry = {
|
| 82 |
+
"time": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()),
|
| 83 |
+
"label": label,
|
| 84 |
+
"status": status,
|
| 85 |
+
"detail": detail,
|
| 86 |
+
}
|
| 87 |
+
TELEGRAM_DELIVERY_LOGS.append(entry)
|
| 88 |
+
if len(TELEGRAM_DELIVERY_LOGS) > 30:
|
| 89 |
+
TELEGRAM_DELIVERY_LOGS.pop(0)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _send_telegram_message(
|
| 93 |
+
token: str,
|
| 94 |
+
chat_id: str,
|
| 95 |
+
text: str,
|
| 96 |
+
parse_mode: str = "HTML",
|
| 97 |
+
max_attempts: int = 3,
|
| 98 |
+
delivery_label: str = "default",
|
| 99 |
+
retry_sleep_seconds: float = 1.0,
|
| 100 |
+
) -> str:
|
| 101 |
+
import urllib.request
|
| 102 |
+
import urllib.parse
|
| 103 |
+
import ssl
|
| 104 |
+
|
| 105 |
+
token = (token or "").strip()
|
| 106 |
+
if token.lower().startswith("bot"):
|
| 107 |
+
token = token[3:].strip()
|
| 108 |
+
|
| 109 |
+
chat_id = (chat_id or "").strip()
|
| 110 |
+
|
| 111 |
+
if not token or not chat_id:
|
| 112 |
+
msg = f"FAILED: Token or Chat ID empty (token_len={len(token)}, chat_id_len={len(chat_id)})"
|
| 113 |
+
_record_telegram_log(delivery_label, "MISSING_CREDS", msg)
|
| 114 |
+
print(f"[TELEGRAM:{delivery_label}] {msg}")
|
| 115 |
+
return msg
|
| 116 |
+
|
| 117 |
+
chunks = _chunk_text(text)
|
| 118 |
+
total_chunks = len(chunks)
|
| 119 |
+
|
| 120 |
+
ssl_ctx = ssl.create_default_context()
|
| 121 |
+
ssl_ctx.check_hostname = False
|
| 122 |
+
ssl_ctx.verify_mode = ssl.CERT_NONE
|
| 123 |
+
|
| 124 |
+
for idx, chunk in enumerate(chunks, 1):
|
| 125 |
+
payload: Dict[str, Any] = {
|
| 126 |
+
"token": token,
|
| 127 |
+
"chat_id": chat_id,
|
| 128 |
+
"text": chunk,
|
| 129 |
+
"disable_web_page_preview": True,
|
| 130 |
+
}
|
| 131 |
+
if parse_mode:
|
| 132 |
+
payload["parse_mode"] = parse_mode
|
| 133 |
+
|
| 134 |
+
sent_chunk = False
|
| 135 |
+
last_error = ""
|
| 136 |
+
|
| 137 |
+
# Attempt 1: Try Vercel Serverless Relay (bypasses HF Space Telegram firewall block)
|
| 138 |
+
relay_url = "https://aruncore.vercel.app/api/telegram"
|
| 139 |
+
try:
|
| 140 |
+
relay_data = json.dumps(payload).encode("utf-8")
|
| 141 |
+
relay_req = urllib.request.Request(
|
| 142 |
+
relay_url,
|
| 143 |
+
data=relay_data,
|
| 144 |
+
headers={"Content-Type": "application/json", "User-Agent": "ArunCore/1.0", "Connection": "close"},
|
| 145 |
+
)
|
| 146 |
+
with urllib.request.urlopen(relay_req, timeout=8, context=ssl_ctx) as resp:
|
| 147 |
+
resp_bytes = resp.read()
|
| 148 |
+
resp_data = json.loads(resp_bytes.decode("utf-8"))
|
| 149 |
+
if resp.status == 200 and resp_data.get("ok"):
|
| 150 |
+
sent_chunk = True
|
| 151 |
+
except Exception as e:
|
| 152 |
+
last_error = f"Vercel Relay error: {e}"
|
| 153 |
+
|
| 154 |
+
if not sent_chunk:
|
| 155 |
+
# Direct Telegram API Fallback
|
| 156 |
+
for attempt in range(1, max_attempts + 1):
|
| 157 |
+
try:
|
| 158 |
+
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
| 159 |
+
direct_payload = {
|
| 160 |
+
"chat_id": chat_id,
|
| 161 |
+
"text": chunk,
|
| 162 |
+
"disable_web_page_preview": True,
|
| 163 |
+
}
|
| 164 |
+
if parse_mode:
|
| 165 |
+
direct_payload["parse_mode"] = parse_mode
|
| 166 |
+
|
| 167 |
+
data = json.dumps(direct_payload).encode("utf-8")
|
| 168 |
+
req = urllib.request.Request(
|
| 169 |
+
url,
|
| 170 |
+
data=data,
|
| 171 |
+
headers={
|
| 172 |
+
"Content-Type": "application/json",
|
| 173 |
+
"User-Agent": "ArunCore/1.0",
|
| 174 |
+
"Connection": "close",
|
| 175 |
+
},
|
| 176 |
+
)
|
| 177 |
+
with urllib.request.urlopen(req, timeout=10, context=ssl_ctx) as resp:
|
| 178 |
+
resp_bytes = resp.read()
|
| 179 |
+
resp_data = json.loads(resp_bytes.decode("utf-8"))
|
| 180 |
+
if resp.status == 200 and resp_data.get("ok"):
|
| 181 |
+
sent_chunk = True
|
| 182 |
+
break
|
| 183 |
+
else:
|
| 184 |
+
last_error = f"HTTP {resp.status}: {resp_bytes.decode('utf-8')[:200]}"
|
| 185 |
+
except Exception as e:
|
| 186 |
+
last_error = str(e)
|
| 187 |
+
|
| 188 |
+
if attempt < max_attempts:
|
| 189 |
+
time.sleep(retry_sleep_seconds)
|
| 190 |
+
|
| 191 |
+
if not sent_chunk and parse_mode == "HTML":
|
| 192 |
+
fallback_payload = {
|
| 193 |
+
"chat_id": chat_id,
|
| 194 |
+
"text": f"[{delivery_label}] (Plain Text Fallback {idx}/{total_chunks})\n{chunk}",
|
| 195 |
+
"disable_web_page_preview": True,
|
| 196 |
+
}
|
| 197 |
+
try:
|
| 198 |
+
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
| 199 |
+
data = json.dumps(fallback_payload).encode("utf-8")
|
| 200 |
+
req = urllib.request.Request(
|
| 201 |
+
url,
|
| 202 |
+
data=data,
|
| 203 |
+
headers={
|
| 204 |
+
"Content-Type": "application/json",
|
| 205 |
+
"User-Agent": "ArunCore/1.0",
|
| 206 |
+
"Connection": "close",
|
| 207 |
+
},
|
| 208 |
+
)
|
| 209 |
+
with urllib.request.urlopen(req, timeout=12, context=ssl_ctx) as resp:
|
| 210 |
+
resp_bytes = resp.read()
|
| 211 |
+
resp_data = json.loads(resp_bytes.decode("utf-8"))
|
| 212 |
+
if resp.status == 200 and resp_data.get("ok"):
|
| 213 |
+
sent_chunk = True
|
| 214 |
+
except Exception as e:
|
| 215 |
+
last_error = f"Fallback error: {e}"
|
| 216 |
+
|
| 217 |
+
if not sent_chunk:
|
| 218 |
+
err_msg = f"FAILED chunk {idx}/{total_chunks}: {last_error}"
|
| 219 |
+
print(f"[TELEGRAM:{delivery_label}] {err_msg}")
|
| 220 |
+
_record_telegram_log(delivery_label, "ERROR", err_msg)
|
| 221 |
+
return err_msg
|
| 222 |
+
|
| 223 |
+
print(f"[TELEGRAM:{delivery_label}] SUCCESS")
|
| 224 |
+
_record_telegram_log(delivery_label, "SUCCESS", f"Delivered {total_chunks} chunk(s)")
|
| 225 |
+
return "SUCCESS: message delivered."
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def send_chat_history_to_telegram(
|
| 229 |
+
session_id: str,
|
| 230 |
+
user_input: str,
|
| 231 |
+
assistant_response: str,
|
| 232 |
+
thoughts: Optional[List[str]] = None,
|
| 233 |
+
tool_calls: Optional[List[str]] = None,
|
| 234 |
+
retrieved_chunks: Optional[List[str]] = None,
|
| 235 |
+
github_data: Optional[List[str]] = None,
|
| 236 |
+
) -> str:
|
| 237 |
+
token, chat_id = _get_telegram_target(debug=False)
|
| 238 |
+
if not token or not chat_id:
|
| 239 |
+
return "FAILED: Telegram credentials missing."
|
| 240 |
+
|
| 241 |
+
clean_user = _escape_html(safe_truncate(user_input, 1500))
|
| 242 |
+
clean_ai = _escape_html(safe_truncate(assistant_response, 2500))
|
| 243 |
+
|
| 244 |
+
tools_html = ""
|
| 245 |
+
if tool_calls and len(tool_calls) > 0:
|
| 246 |
+
tools_str = "\n".join([f"β’ <code>{_escape_html(safe_truncate(t, 250))}</code>" for t in tool_calls])
|
| 247 |
+
tools_html = f"\n\n<b>π§ AI Decisions & Tool Calls:</b>\n{tools_str}"
|
| 248 |
+
|
| 249 |
+
chunks_html = ""
|
| 250 |
+
if retrieved_chunks and len(retrieved_chunks) > 0:
|
| 251 |
+
chunks_str = "\n".join([f"--- Chunk {i+1} ---\n{_escape_html(safe_truncate(c, 500))}" for i, c in enumerate(retrieved_chunks[:3])])
|
| 252 |
+
chunks_html = f"\n\n<b>π RAG Knowledge Chunks Retrieved:</b>\n<code>{chunks_str}</code>"
|
| 253 |
+
|
| 254 |
+
github_html = ""
|
| 255 |
+
if github_data and len(github_data) > 0:
|
| 256 |
+
github_str = "\n".join([f"--- Repo Data ---\n{_escape_html(safe_truncate(g, 500))}" for g in github_data[:2]])
|
| 257 |
+
github_html = f"\n\n<b>π Live GitHub Repositories Fetched:</b>\n<code>{github_str}</code>"
|
| 258 |
+
|
| 259 |
+
thoughts_html = ""
|
| 260 |
+
if thoughts and len(thoughts) > 0:
|
| 261 |
+
thoughts_str = "\n".join([f"β’ {_escape_html(t)}" for t in thoughts])
|
| 262 |
+
thoughts_html = f"\n\n<b>βοΈ Execution Steps:</b>\n{thoughts_str}"
|
| 263 |
+
|
| 264 |
+
html = (
|
| 265 |
+
f"<b>π ARUNCORE FULL EXECUTION TRACE</b>\n"
|
| 266 |
+
f"<b>Session ID:</b> <code>{_escape_html(session_id)}</code>\n\n"
|
| 267 |
+
f"<b>π€ User Question:</b>\n{clean_user}"
|
| 268 |
+
f"{tools_html}"
|
| 269 |
+
f"{chunks_html}"
|
| 270 |
+
f"{github_html}"
|
| 271 |
+
f"{thoughts_html}\n\n"
|
| 272 |
+
f"<b>π€ AI Twin Final Reply:</b>\n{clean_ai}"
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
return _send_telegram_message(
|
| 276 |
+
token=token,
|
| 277 |
+
chat_id=chat_id,
|
| 278 |
+
text=html,
|
| 279 |
+
parse_mode="HTML",
|
| 280 |
+
delivery_label="chat_log",
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def queue_chat_history_to_telegram(
|
| 285 |
+
session_id: str,
|
| 286 |
+
user_input: str,
|
| 287 |
+
assistant_response: str,
|
| 288 |
+
thoughts: Optional[List[str]] = None,
|
| 289 |
+
tool_calls: Optional[List[str]] = None,
|
| 290 |
+
retrieved_chunks: Optional[List[str]] = None,
|
| 291 |
+
github_data: Optional[List[str]] = None,
|
| 292 |
+
) -> str:
|
| 293 |
+
submit_background_task(
|
| 294 |
+
"chat_history_log",
|
| 295 |
+
send_chat_history_to_telegram,
|
| 296 |
+
session_id,
|
| 297 |
+
user_input,
|
| 298 |
+
assistant_response,
|
| 299 |
+
thoughts or [],
|
| 300 |
+
tool_calls or [],
|
| 301 |
+
retrieved_chunks or [],
|
| 302 |
+
github_data or [],
|
| 303 |
+
)
|
| 304 |
+
return "QUEUED: chat history scheduled."
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def send_debug_event_to_telegram(
|
| 308 |
+
event_type: str,
|
| 309 |
+
payload_summary: str,
|
| 310 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 311 |
+
) -> str:
|
| 312 |
+
if not _is_telegram_debug_enabled():
|
| 313 |
+
return "SKIPPED: debug disabled."
|
| 314 |
+
|
| 315 |
+
token, chat_id = _get_telegram_target(debug=True)
|
| 316 |
+
if not token or not chat_id:
|
| 317 |
+
return "FAILED: Telegram debug credentials missing."
|
| 318 |
+
|
| 319 |
+
clean_event = _escape_html(event_type.upper())
|
| 320 |
+
clean_payload = _escape_html(safe_truncate(payload_summary, 2000))
|
| 321 |
+
|
| 322 |
+
meta_str = ""
|
| 323 |
+
if metadata:
|
| 324 |
+
meta_str = "\n".join([f"β’ <b>{_escape_html(str(k))}:</b> {_escape_html(str(v))}" for k, v in metadata.items()])
|
| 325 |
+
|
| 326 |
+
html = f"<b>π§ DEBUG EVENT: {clean_event}</b>\n{meta_str}\n\n<code>{clean_payload}</code>"
|
| 327 |
+
|
| 328 |
+
return _send_telegram_message(
|
| 329 |
+
token=token,
|
| 330 |
+
chat_id=chat_id,
|
| 331 |
+
text=html,
|
| 332 |
+
parse_mode="HTML",
|
| 333 |
+
delivery_label="debug_event",
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def queue_debug_event(
|
| 338 |
+
event_type: str,
|
| 339 |
+
payload_summary: str,
|
| 340 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 341 |
+
) -> None:
|
| 342 |
+
submit_background_task(
|
| 343 |
+
"debug_event",
|
| 344 |
+
send_debug_event_to_telegram,
|
| 345 |
+
event_type,
|
| 346 |
+
payload_summary,
|
| 347 |
+
metadata,
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def send_automated_chat_alert(
|
| 352 |
+
session_id: str,
|
| 353 |
+
user_input: str,
|
| 354 |
+
assistant_response: str,
|
| 355 |
+
) -> str:
|
| 356 |
+
token, chat_id = _get_telegram_target(alert=True)
|
| 357 |
+
if not token or not chat_id:
|
| 358 |
+
return "FAILED: Telegram credentials missing."
|
| 359 |
+
|
| 360 |
+
admin_tok = generate_admin_token(session_id)
|
| 361 |
+
join_link = f"https://aruncore.vercel.app/?session_id={session_id}&admin_token={admin_tok}"
|
| 362 |
+
|
| 363 |
+
clean_user = _escape_html(safe_truncate(user_input, 1000))
|
| 364 |
+
clean_ai = _escape_html(safe_truncate(assistant_response, 1500))
|
| 365 |
+
|
| 366 |
+
html = (
|
| 367 |
+
f"π¨ <b>LIVE WEBSITE CHAT ACTIVITY</b>\n"
|
| 368 |
+
f"<b>Session ID:</b> <code>{_escape_html(session_id)}</code>\n\n"
|
| 369 |
+
f"<b>π€ User Question:</b>\n{clean_user}\n\n"
|
| 370 |
+
f"<b>π€ AI Response:</b>\n{clean_ai}\n\n"
|
| 371 |
+
f"π <b><a href=\"{join_link}\">π CLICK HERE TO JOIN LIVE CHAT AS REAL ARUN</a></b>"
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
return _send_telegram_message(
|
| 375 |
+
token=token,
|
| 376 |
+
chat_id=chat_id,
|
| 377 |
+
text=html,
|
| 378 |
+
parse_mode="HTML",
|
| 379 |
+
delivery_label="every_chat_alert",
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def queue_automated_chat_alert(session_id: str, user_input: str, assistant_response: str) -> None:
|
| 384 |
+
submit_background_task(
|
| 385 |
+
"automated_chat_alert_bg",
|
| 386 |
+
send_automated_chat_alert,
|
| 387 |
+
session_id,
|
| 388 |
+
user_input,
|
| 389 |
+
assistant_response,
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def queue_maybe_notify_arun(
|
| 394 |
+
user_input: str,
|
| 395 |
+
reason: str = "",
|
| 396 |
+
channel: str = "api",
|
| 397 |
+
session_id: str = "",
|
| 398 |
+
final_response: str = "",
|
| 399 |
+
scratchpad: Optional[list] = None,
|
| 400 |
+
tool_map: Optional[dict] = None,
|
| 401 |
+
user_metadata: Optional[dict] = None,
|
| 402 |
+
pre_notified: bool = False,
|
| 403 |
+
) -> None:
|
| 404 |
+
"""Escalation hook: schedules an URGENT notify when contact intent is detected."""
|
| 405 |
+
submit_background_task(
|
| 406 |
+
"maybe_notify_arun",
|
| 407 |
+
_deliver_notify_arun,
|
| 408 |
+
"URGENT",
|
| 409 |
+
f"Reason: {reason or 'General check'}\nQuery: {user_input}",
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
_RECENT_ALERTS: Dict[str, float] = {}
|
| 414 |
+
_ALERT_DEDUP_WINDOW_SECONDS = 10.0
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def _should_send_alert(category: str, user_input: str) -> bool:
|
| 418 |
+
now = time.time()
|
| 419 |
+
|
| 420 |
+
expired_keys = [k for k, timestamp in _RECENT_ALERTS.items() if now - timestamp > _ALERT_DEDUP_WINDOW_SECONDS]
|
| 421 |
+
for k in expired_keys:
|
| 422 |
+
_RECENT_ALERTS.pop(k, None)
|
| 423 |
+
|
| 424 |
+
key = f"{category.upper()}:{user_input.strip().lower()}"
|
| 425 |
+
if key in _RECENT_ALERTS:
|
| 426 |
+
return False
|
| 427 |
+
|
| 428 |
+
_RECENT_ALERTS[key] = now
|
| 429 |
+
return True
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
ALLOWED_NOTIFY_CATEGORIES = {
|
| 433 |
+
"UNKNOWN_QUESTION",
|
| 434 |
+
"URGENT",
|
| 435 |
+
"FEEDBACK",
|
| 436 |
+
"SYSTEM_ALERT",
|
| 437 |
+
"LEAD",
|
| 438 |
+
"ABUSE",
|
| 439 |
+
"WEIRD",
|
| 440 |
+
"SUSPICIOUS",
|
| 441 |
+
"OFF_TOPIC",
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
_CATEGORY_HEADERS = {
|
| 445 |
+
"UNKNOWN_QUESTION": "π€· UNKNOWN QUESTION",
|
| 446 |
+
"LEAD": "πΌ NEW LEAD / HIRING INQUIRY",
|
| 447 |
+
"URGENT": "π¨ URGENT ALERT",
|
| 448 |
+
"ABUSE": "π€¬ ABUSE / RUDE MESSAGE",
|
| 449 |
+
"WEIRD": "π WEIRD / UNUSUAL MESSAGE",
|
| 450 |
+
"SUSPICIOUS": "π΅οΈ SUSPICIOUS ACTIVITY",
|
| 451 |
+
"OFF_TOPIC": "π« OFF-TOPIC / IRRELEVANT",
|
| 452 |
+
"FEEDBACK": "π¬ FEEDBACK",
|
| 453 |
+
"SYSTEM_ALERT": "βοΈ SYSTEM ALERT",
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def _deliver_notify_arun(
|
| 458 |
+
category: str,
|
| 459 |
+
user_input: str,
|
| 460 |
+
user_metadata_json: str = "",
|
| 461 |
+
_fast: bool = False,
|
| 462 |
+
) -> str:
|
| 463 |
+
token, chat_id = _get_telegram_target(alert=True)
|
| 464 |
+
|
| 465 |
+
if not token or not chat_id:
|
| 466 |
+
msg = "FAILED: Telegram credentials missing."
|
| 467 |
+
_record_telegram_log("notify_arun_bg", "MISSING_CREDS", msg)
|
| 468 |
+
return msg
|
| 469 |
+
|
| 470 |
+
category = (category or "UNKNOWN_QUESTION").strip().upper()
|
| 471 |
+
if category not in ALLOWED_NOTIFY_CATEGORIES:
|
| 472 |
+
category = "UNKNOWN_QUESTION"
|
| 473 |
+
|
| 474 |
+
cleaned_input = safe_truncate(user_input, 1200)
|
| 475 |
+
|
| 476 |
+
if not _should_send_alert(category, cleaned_input):
|
| 477 |
+
msg = f"SKIPPED: duplicate {category} alert suppressed within 10s window."
|
| 478 |
+
_record_telegram_log("notify_arun_bg", "SKIPPED_DEDUP", msg)
|
| 479 |
+
return msg
|
| 480 |
+
|
| 481 |
+
header = _CATEGORY_HEADERS.get(category, f"π¨ ALERT: {category}")
|
| 482 |
+
html = (
|
| 483 |
+
f"<b>{header}</b>\n\n"
|
| 484 |
+
f"<b>User Query / Details:</b>\n{_escape_html(cleaned_input)}\n\n"
|
| 485 |
+
f"<b>Category:</b> {category}\n"
|
| 486 |
+
f"<b>Contact:</b> +91 8881109193 | neural.arun.dev@gmail.com\n\n"
|
| 487 |
+
f"<i>π‘ Reply directly to this message to save your answer into AI memory!</i>"
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
return _send_telegram_message(
|
| 491 |
+
token=token,
|
| 492 |
+
chat_id=chat_id,
|
| 493 |
+
text=html,
|
| 494 |
+
parse_mode="HTML",
|
| 495 |
+
delivery_label="notify_arun_bg",
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def schedule_notify_arun(category: str, user_input: str, user_metadata_json: str = "") -> str:
|
| 500 |
+
"""Queue an urgent notify_arun Telegram alert (used by the agent tool)."""
|
| 501 |
+
submit_background_task(
|
| 502 |
+
"notify_arun_bg",
|
| 503 |
+
_deliver_notify_arun,
|
| 504 |
+
category,
|
| 505 |
+
user_input,
|
| 506 |
+
user_metadata_json or "",
|
| 507 |
+
)
|
| 508 |
+
return "QUEUED: notify_arun alert scheduled."
|
backend/app/services/prompt_builder.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompt assembly for the ArunCore agent.
|
| 2 |
+
|
| 3 |
+
Every tenant-facing prompt (both the legacy demos dictionary and the split
|
| 4 |
+
tenant configs) flows through here. The builder returns the exact same prompt
|
| 5 |
+
templates the legacy monolith produced, so prompt behavior is unchanged while
|
| 6 |
+
the assembly itself lives in a single-responsibility service.
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
| 12 |
+
from langchain_core.messages import SystemMessage
|
| 13 |
+
|
| 14 |
+
from backend.app.services.tenant_service import TenantService
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _backend_app_dir() -> str:
|
| 18 |
+
"""backend/app/ (parent of services/, core/, prompts/, ...)."""
|
| 19 |
+
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _project_root() -> str:
|
| 23 |
+
"""profile/ (repository root)."""
|
| 24 |
+
return os.path.dirname(_backend_app_dir())
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_static_context() -> Tuple[str, str, str, str, str]:
|
| 28 |
+
"""Returns (system_prompt, guardrails, handoff, profile, rules).
|
| 29 |
+
|
| 30 |
+
The prompts/ directory may be absent on a fresh checkout; every missing
|
| 31 |
+
file simply resolves to an empty string, matching the legacy loader.
|
| 32 |
+
"""
|
| 33 |
+
backend_app = _backend_app_dir()
|
| 34 |
+
root_dir = _project_root()
|
| 35 |
+
|
| 36 |
+
sys_prompt_path = os.path.join(backend_app, "prompts", "system_prompt.md")
|
| 37 |
+
guardrails_path = os.path.join(backend_app, "prompts", "guardrails.md")
|
| 38 |
+
handoff_path = os.path.join(backend_app, "prompts", "handoff_prompt.md")
|
| 39 |
+
|
| 40 |
+
profile_path = os.path.join(root_dir, "data", "static", "public_profile.md")
|
| 41 |
+
rules_path = os.path.join(root_dir, "data", "static", "rules_of_engagement.md")
|
| 42 |
+
|
| 43 |
+
def _read(path: str) -> str:
|
| 44 |
+
if os.path.exists(path):
|
| 45 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 46 |
+
return f.read()
|
| 47 |
+
return ""
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
_read(sys_prompt_path),
|
| 51 |
+
_read(guardrails_path),
|
| 52 |
+
_read(handoff_path),
|
| 53 |
+
_read(profile_path),
|
| 54 |
+
_read(rules_path),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class PromptBuilder:
|
| 59 |
+
"""Constructs the final system prompt + chat template for an agent run."""
|
| 60 |
+
|
| 61 |
+
def __init__(self, tenant_service_: Optional[TenantService] = None):
|
| 62 |
+
self.tenant_service = tenant_service_ or TenantService()
|
| 63 |
+
|
| 64 |
+
def build_system_prompt(self, tutor_id: Optional[str] = None) -> str:
|
| 65 |
+
"""Assembles the system prompt for either a tenant (demos dict) or the
|
| 66 |
+
default Arun twin persona. Contains a literal ``{running_summary}``
|
| 67 |
+
placeholder that the chat template fills per message."""
|
| 68 |
+
tutor_cfg = self.tenant_service.load_legacy_tutor_config(tutor_id)
|
| 69 |
+
|
| 70 |
+
if tutor_cfg:
|
| 71 |
+
backend_cfg = tutor_cfg.get("backend_llm_configuration", {})
|
| 72 |
+
sys_prompt_cfg = backend_cfg.get("system_prompt", {})
|
| 73 |
+
|
| 74 |
+
custom_prompt = sys_prompt_cfg.get("persona_identity") or tutor_cfg.get("custom_system_prompt") or "You are an AI Course Advisor."
|
| 75 |
+
rules_list = sys_prompt_cfg.get("project_guidelines") or tutor_cfg.get("custom_rules") or []
|
| 76 |
+
courses_data = tutor_cfg.get("courses") or []
|
| 77 |
+
about_bio = tutor_cfg.get("frontend_ui_dictionary", {}).get("about_view", {}).get("bio_paragraphs") or tutor_cfg.get("about_text") or ""
|
| 78 |
+
|
| 79 |
+
rules_txt = "\n".join([f"- {r}" for r in rules_list])
|
| 80 |
+
courses_txt = json_dumps_safe(courses_data)
|
| 81 |
+
about_txt = "\n".join(about_bio) if isinstance(about_bio, list) else str(about_bio)
|
| 82 |
+
|
| 83 |
+
return f"""
|
| 84 |
+
{custom_prompt}
|
| 85 |
+
|
| 86 |
+
--- INSTRUCTOR & COURSE KNOWLEDGE ---
|
| 87 |
+
About Instructor:
|
| 88 |
+
{about_txt}
|
| 89 |
+
|
| 90 |
+
Available Courses & Cohorts:
|
| 91 |
+
{courses_txt}
|
| 92 |
+
|
| 93 |
+
--- SPECIAL INSTRUCTIONS & RULES ---
|
| 94 |
+
{rules_txt}
|
| 95 |
+
|
| 96 |
+
--- LANGUAGE RULES ---
|
| 97 |
+
Always respond in the exact language used by the student (English or natural Hinglish).
|
| 98 |
+
|
| 99 |
+
--- PAST CONVERSATION SUMMARY ---
|
| 100 |
+
{{running_summary}}
|
| 101 |
+
"""
|
| 102 |
+
|
| 103 |
+
sys_content, guard_content, handoff_content, profile, rules = load_static_context()
|
| 104 |
+
|
| 105 |
+
return f"""
|
| 106 |
+
--- MASTER RULES OF ENGAGEMENT & PERSONA (PRIMARY CORE) ---
|
| 107 |
+
{rules}
|
| 108 |
+
|
| 109 |
+
--- IDENTITY PROFILE & TECHNICAL SPECIFICATIONS ---
|
| 110 |
+
{profile}
|
| 111 |
+
|
| 112 |
+
--- GUARDRAILS & STEERING RULES ---
|
| 113 |
+
{guard_content}
|
| 114 |
+
|
| 115 |
+
--- 3-WAY LIVE CHAT & HANDOFF RULES ---
|
| 116 |
+
{handoff_content}
|
| 117 |
+
|
| 118 |
+
--- PAST CONVERSATION SUMMARY ---
|
| 119 |
+
{{running_summary}}
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
@staticmethod
|
| 123 |
+
def build_chat_prompt(system_prompt: str) -> ChatPromptTemplate:
|
| 124 |
+
return ChatPromptTemplate.from_messages(
|
| 125 |
+
[
|
| 126 |
+
("system", system_prompt),
|
| 127 |
+
MessagesPlaceholder(variable_name="chat_history"),
|
| 128 |
+
("human", "{input}"),
|
| 129 |
+
MessagesPlaceholder(variable_name="agent_scratchpad"),
|
| 130 |
+
]
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
@staticmethod
|
| 134 |
+
def inject_live_human_notice(messages: List[Any], arun_human_msgs: List[Dict[str, Any]]) -> List[Any]:
|
| 135 |
+
"""Inserts the 3-way live chat presence notice when the real human is active."""
|
| 136 |
+
if not arun_human_msgs:
|
| 137 |
+
return messages
|
| 138 |
+
|
| 139 |
+
formatted_msgs = "\n".join([f"β’ Real Human Instructor (οΏ½οΏ½βπ»): \"{m.get('text')}\"" for m in arun_human_msgs])
|
| 140 |
+
live_notice = SystemMessage(content=(
|
| 141 |
+
f"π’ CRITICAL LIVE 3-WAY CHAT NOTICE (REAL HUMAN INSTRUCTOR IS PRESENT):\n"
|
| 142 |
+
f"The REAL HUMAN INSTRUCTOR (π¨βπ») HAS JOINED THIS CHAT ROOM LIVE AND IS CURRENTLY CHATTING!\n\n"
|
| 143 |
+
f"REAL INSTRUCTOR'S MESSAGES IN THIS SESSION:\n{formatted_msgs}\n\n"
|
| 144 |
+
f"MANDATORY INSTRUCTIONS FOR AI ASSISTANT IN THIS 3-WAY CHAT:\n"
|
| 145 |
+
f"1. Acknowledge that the REAL human instructor is present right next to you in this chat session!\n"
|
| 146 |
+
f"2. If the user asks how the instructor came here or questions about their arrival, explain enthusiastically: \"The real instructor tapped their 1-Click Telegram link and joined our chat live from their phone! So both of us (Real Instructor + AI Assistant) are here together with you!\"\n"
|
| 147 |
+
f"3. Never confuse yourself as the human β you are the AI Assistant co-piloting alongside the Real Instructor!"
|
| 148 |
+
))
|
| 149 |
+
|
| 150 |
+
new_messages = list(messages)
|
| 151 |
+
if len(new_messages) > 1:
|
| 152 |
+
new_messages.insert(1, live_notice)
|
| 153 |
+
else:
|
| 154 |
+
new_messages.append(live_notice)
|
| 155 |
+
return new_messages
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def json_dumps_safe(obj: Any) -> str:
|
| 159 |
+
"""dump with braces escaped so the LLM output isn't treated as prompt vars."""
|
| 160 |
+
import json
|
| 161 |
+
|
| 162 |
+
return json.dumps(obj, indent=2).replace("{", "{{").replace("}", "}}")
|
backend/app/services/rag_service.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hybrid RAG coordinator.
|
| 2 |
+
|
| 3 |
+
Composes the knowledge-retrieval pipeline for a tenant: dense vector search
|
| 4 |
+
(ChromaDB), sparse keyword search (BM25), and reranking get their turn here
|
| 5 |
+
in future integration; today ChromaDB is built at ingest time and runtime
|
| 6 |
+
retrieval is served by `KnowledgeService` (project READMEs + LinkedIn posts +
|
| 7 |
+
profile sections + verified Q&A pairs). This service also doubles as the
|
| 8 |
+
active-learning ingestion point for owner-verified answers.
|
| 9 |
+
"""
|
| 10 |
+
import hashlib
|
| 11 |
+
from typing import List, Dict, Any
|
| 12 |
+
|
| 13 |
+
from backend.app.services.knowledge_service import knowledge_service
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class RAGService:
|
| 17 |
+
"""Hybrid RAG coordinator: retrieval + active-learning ingestion per tenant."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, tenant_id: str = "arun"):
|
| 20 |
+
self.tenant_id = tenant_id
|
| 21 |
+
|
| 22 |
+
def retrieve_context(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
|
| 23 |
+
"""Executes high-precision hybrid retrieval for the specified tenant."""
|
| 24 |
+
print(f"[RAG_SERVICE] Retrieving context for tenant '{self.tenant_id}' query: '{query[:80]}'")
|
| 25 |
+
|
| 26 |
+
raw = knowledge_service.search(query)
|
| 27 |
+
|
| 28 |
+
chunks: List[Dict[str, Any]] = []
|
| 29 |
+
if raw:
|
| 30 |
+
parts = [p for p in raw.split("\n\n--- ") if p.strip()]
|
| 31 |
+
for i, part in enumerate(parts[:top_k]):
|
| 32 |
+
chunks.append({
|
| 33 |
+
"id": f"{self.tenant_id}_chunk_{i}_{hashlib.md5(part.encode('utf-8')).hexdigest()[:8]}",
|
| 34 |
+
"content": part.strip(),
|
| 35 |
+
"score": max(0.0, 0.95 - (i * 0.03)),
|
| 36 |
+
"source": "knowledge_base",
|
| 37 |
+
})
|
| 38 |
+
return chunks
|
| 39 |
+
|
| 40 |
+
def add_knowledge_entry(self, question: str, answer: str) -> bool:
|
| 41 |
+
"""Persists a verified Q&A into unknown_questions.json and re-ingests."""
|
| 42 |
+
try:
|
| 43 |
+
result = knowledge_service.save_verified_answer(question, answer)
|
| 44 |
+
print(f"[RAG_SERVICE] Active-learning entry for tenant '{self.tenant_id}': {result}")
|
| 45 |
+
return True
|
| 46 |
+
except Exception as e:
|
| 47 |
+
print(f"[RAG_SERVICE ERROR] Failed to persist Q&A for tenant '{self.tenant_id}': {e}")
|
| 48 |
+
return False
|
backend/app/services/session_store.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thread-safe in-memory session state for the ArunCore backend.
|
| 2 |
+
|
| 3 |
+
Consolidates all live websocket-like stores that were previously scattered
|
| 4 |
+
through the API module:
|
| 5 |
+
|
| 6 |
+
* message history per session (visitor + twin + real-human entries)
|
| 7 |
+
* human-owned messages
|
| 8 |
+
* human-control flags (3-way live chat takeover)
|
| 9 |
+
* rolling memory objects per session
|
| 10 |
+
|
| 11 |
+
All reads/writes are guarded by a global lock so concurrent chat streams
|
| 12 |
+
cannot corrupt shared dicts.
|
| 13 |
+
"""
|
| 14 |
+
import os
|
| 15 |
+
import time
|
| 16 |
+
import datetime
|
| 17 |
+
import threading
|
| 18 |
+
from typing import Any, Dict, List, Optional
|
| 19 |
+
|
| 20 |
+
from langchain_openai import ChatOpenAI
|
| 21 |
+
|
| 22 |
+
from backend.app.services.memory_manager import RollingMemory
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _default_summary_llm():
|
| 26 |
+
return ChatOpenAI(
|
| 27 |
+
temperature=0.0,
|
| 28 |
+
model=os.getenv("MEMORY_SUMMARY_MODEL", "gpt-4.1-nano"),
|
| 29 |
+
api_key=os.getenv("OPENAI_API_KEY"),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class SessionStore:
|
| 34 |
+
"""Single ownership point for every in-memory session artifact."""
|
| 35 |
+
|
| 36 |
+
def __init__(self, summary_llm_factory: Any = None):
|
| 37 |
+
self._lock = threading.RLock()
|
| 38 |
+
self._history: Dict[str, List[Dict[str, Any]]] = {}
|
| 39 |
+
self._human_messages: Dict[str, List[Dict[str, Any]]] = {}
|
| 40 |
+
self._human_control: Dict[str, bool] = {}
|
| 41 |
+
self._memories: Dict[str, RollingMemory] = {}
|
| 42 |
+
self._summary_llm_factory = summary_llm_factory or _default_summary_llm
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def liveness(self) -> int:
|
| 46 |
+
return len(self._memories)
|
| 47 |
+
|
| 48 |
+
# ------------------------------------------------------------------ #
|
| 49 |
+
# Message history
|
| 50 |
+
# ------------------------------------------------------------------ #
|
| 51 |
+
def record_message(self, session_id, sender, text, name="", thoughts=None):
|
| 52 |
+
with self._lock:
|
| 53 |
+
now_str = datetime.datetime.now().strftime("%I:%M %p")
|
| 54 |
+
entry = {
|
| 55 |
+
"id": f"msg_{sender}_{int(time.time() * 1000)}_{len(self._history.get(session_id, []))}",
|
| 56 |
+
"sender": sender,
|
| 57 |
+
"name": name or (
|
| 58 |
+
"Arun Yadav" if sender == "human_arun"
|
| 59 |
+
else "Arun's AI Assistant" if sender == "twin"
|
| 60 |
+
else "You"
|
| 61 |
+
),
|
| 62 |
+
"text": text,
|
| 63 |
+
"timestamp": now_str,
|
| 64 |
+
}
|
| 65 |
+
if thoughts:
|
| 66 |
+
entry["thoughts"] = thoughts
|
| 67 |
+
|
| 68 |
+
if session_id not in self._history:
|
| 69 |
+
self._history[session_id] = []
|
| 70 |
+
self._history[session_id].append(entry)
|
| 71 |
+
return entry
|
| 72 |
+
|
| 73 |
+
def get_history(self, session_id: str) -> List[Dict[str, Any]]:
|
| 74 |
+
with self._lock:
|
| 75 |
+
return list(self._history.get(session_id, []))
|
| 76 |
+
|
| 77 |
+
def get_human_messages(self, session_id: str) -> List[Dict[str, Any]]:
|
| 78 |
+
with self._lock:
|
| 79 |
+
return list(self._human_messages.get(session_id, []))
|
| 80 |
+
|
| 81 |
+
def append_human_message(self, session_id: str, entry: Dict[str, Any]) -> None:
|
| 82 |
+
with self._lock:
|
| 83 |
+
self._human_messages.setdefault(session_id, []).append(entry)
|
| 84 |
+
|
| 85 |
+
def get_human_entries(self, session_id: str) -> List[Dict[str, Any]]:
|
| 86 |
+
"""Channel messages authored by the real human within this session."""
|
| 87 |
+
with self._lock:
|
| 88 |
+
return [m for m in self._history.get(session_id, []) if m.get("sender") == "human_arun"]
|
| 89 |
+
|
| 90 |
+
def get_last_user_message(self, session_id: str) -> str:
|
| 91 |
+
with self._lock:
|
| 92 |
+
for m in reversed(self._history.get(session_id, [])):
|
| 93 |
+
if m.get("sender") == "user":
|
| 94 |
+
return m.get("text", "")
|
| 95 |
+
return ""
|
| 96 |
+
|
| 97 |
+
# ------------------------------------------------------------------ #
|
| 98 |
+
# Human-control (3-way live takeover)
|
| 99 |
+
# ------------------------------------------------------------------ #
|
| 100 |
+
def is_human_control(self, session_id: str) -> bool:
|
| 101 |
+
with self._lock:
|
| 102 |
+
return bool(self._human_control.get(session_id, False))
|
| 103 |
+
|
| 104 |
+
def set_human_control(self, session_id: str, enabled: bool) -> None:
|
| 105 |
+
with self._lock:
|
| 106 |
+
self._human_control[session_id] = enabled
|
| 107 |
+
|
| 108 |
+
# ------------------------------------------------------------------ #
|
| 109 |
+
# Rolling memory per session
|
| 110 |
+
# ------------------------------------------------------------------ #
|
| 111 |
+
def get_or_create_memory(self, session_id: str) -> RollingMemory:
|
| 112 |
+
with self._lock:
|
| 113 |
+
if session_id not in self._memories:
|
| 114 |
+
self._memories[session_id] = RollingMemory(
|
| 115 |
+
summary_llm=self._summary_llm_factory(),
|
| 116 |
+
)
|
| 117 |
+
return self._memories[session_id]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# Backwards-compatible global store (used by the FastAPI layer).
|
| 121 |
+
session_store = SessionStore()
|
backend/app/services/tenant_service.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
from typing import Optional, Dict, Any
|
| 4 |
+
from backend.app.schemas.tenant import (
|
| 5 |
+
BrandConfig, AgentConfig, ChatConfig, VoiceConfig, SEOConfig, SocialConfig, TenantFullConfig
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TenantService:
|
| 10 |
+
def __init__(self, base_dir: Optional[str] = None):
|
| 11 |
+
if base_dir:
|
| 12 |
+
self.base_dir = base_dir
|
| 13 |
+
else:
|
| 14 |
+
self.base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
| 15 |
+
self.tenants_dir = os.path.join(self.base_dir, "tenants")
|
| 16 |
+
|
| 17 |
+
def get_tenant_path(self, tutor_id: str) -> str:
|
| 18 |
+
clean_id = (tutor_id or "arun").strip().lower()
|
| 19 |
+
path = os.path.join(self.tenants_dir, clean_id)
|
| 20 |
+
if os.path.exists(path):
|
| 21 |
+
return path
|
| 22 |
+
return os.path.join(self.tenants_dir, "tenant_starter")
|
| 23 |
+
|
| 24 |
+
def load_tenant_config(self, tutor_id: Optional[str] = None) -> TenantFullConfig:
|
| 25 |
+
clean_id = (tutor_id or "arun").strip().lower()
|
| 26 |
+
if clean_id in ("arun", "default", "none"):
|
| 27 |
+
clean_id = "tenant_starter"
|
| 28 |
+
|
| 29 |
+
tenant_path = self.get_tenant_path(clean_id)
|
| 30 |
+
resolved_id = os.path.basename(tenant_path)
|
| 31 |
+
config_dir = os.path.join(tenant_path, "config")
|
| 32 |
+
|
| 33 |
+
# Load split JSON configs
|
| 34 |
+
brand_data = self._read_json(os.path.join(config_dir, "brand.json"), {"tutor_id": resolved_id, "name": "Arun Yadav", "title": "Arun's AI Assistant", "role": "AI Systems Architect β’ Healthcare & Education", "avatar_url": "/profile_photo.png", "logo_url": "/logo.jpg", "primary_color": "#6366f1", "accent_color": "#818cf8", "theme": "dark", "cta_text": "Consult Arun"})
|
| 35 |
+
agent_data = self._read_json(os.path.join(config_dir, "agent.json"), {"tutor_id": resolved_id, "system_prompt": "You are Arun's AI Assistant.", "temperature": 0.3, "model": "gpt-4o", "enabled_tools": ["search_courses", "book_calendar", "faq_lookup"], "guardrails": []})
|
| 36 |
+
chat_data = self._read_json(os.path.join(config_dir, "chat.json"), {"tutor_id": resolved_id, "welcome_message": "Hi! I'm Arun's AI Assistant.", "suggested_questions": ["What projects has Arun built?", "How can I contact Arun?"]})
|
| 37 |
+
voice_data = self._read_json(os.path.join(config_dir, "voice.json"), {"tutor_id": resolved_id, "voice_id": "alloy", "model": "tts-1", "speed": 1.0})
|
| 38 |
+
seo_data = self._read_json(os.path.join(config_dir, "seo.json"), {"tutor_id": resolved_id, "meta_title": "Arun Core", "meta_description": "AI Platform", "keywords": []})
|
| 39 |
+
social_data = self._read_json(os.path.join(config_dir, "social.json"), {"tutor_id": resolved_id, "website": "https://neuralarun.in"})
|
| 40 |
+
|
| 41 |
+
brand_data["tutor_id"] = resolved_id
|
| 42 |
+
agent_data["tutor_id"] = resolved_id
|
| 43 |
+
chat_data["tutor_id"] = resolved_id
|
| 44 |
+
voice_data["tutor_id"] = resolved_id
|
| 45 |
+
seo_data["tutor_id"] = resolved_id
|
| 46 |
+
social_data["tutor_id"] = resolved_id
|
| 47 |
+
|
| 48 |
+
return TenantFullConfig(
|
| 49 |
+
tutor_id=resolved_id,
|
| 50 |
+
brand=BrandConfig(**brand_data),
|
| 51 |
+
agent=AgentConfig(**agent_data),
|
| 52 |
+
chat=ChatConfig(**chat_data),
|
| 53 |
+
voice=VoiceConfig(**voice_data),
|
| 54 |
+
seo=SEOConfig(**seo_data),
|
| 55 |
+
social=SocialConfig(**social_data),
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
def _read_json(self, filepath: str, fallback: Dict[str, Any]) -> Dict[str, Any]:
|
| 59 |
+
if os.path.exists(filepath):
|
| 60 |
+
try:
|
| 61 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 62 |
+
return json.load(f)
|
| 63 |
+
except Exception as e:
|
| 64 |
+
print(f"[TENANT_SERVICE WARNING] Failed to parse {filepath}: {e}")
|
| 65 |
+
return fallback
|
| 66 |
+
|
| 67 |
+
# ------------------------------------------------------------------ #
|
| 68 |
+
# Legacy demos-dictionary loader (pre-dates split configs). #
|
| 69 |
+
# Kept here so both config systems resolve in one service and the #
|
| 70 |
+
# agent loop reuses it for `?tutor=<id>` responses. #
|
| 71 |
+
# ------------------------------------------------------------------ #
|
| 72 |
+
def load_legacy_tutor_config(self, tutor_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
| 73 |
+
slug = (tutor_id or "arun").strip().lower()
|
| 74 |
+
|
| 75 |
+
candidate_paths = [
|
| 76 |
+
os.path.join(self.base_dir, "demos", f"{slug}_enterprise_dictionary.json"),
|
| 77 |
+
os.path.join(self.base_dir, "demos", f"{slug}.json"),
|
| 78 |
+
os.path.join(self.base_dir, "data", "leads", f"{slug}_enterprise_dictionary.json"),
|
| 79 |
+
os.path.join(self.base_dir, "data", "leads", f"{slug}.json"),
|
| 80 |
+
os.path.join(self.base_dir, "demos", "general.json"),
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
for path in candidate_paths:
|
| 84 |
+
if os.path.exists(path):
|
| 85 |
+
try:
|
| 86 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 87 |
+
data = json.load(f)
|
| 88 |
+
if data.get("client_id") == "{{TUTOR_ID}}":
|
| 89 |
+
data["client_id"] = slug
|
| 90 |
+
data["tutor_id"] = slug
|
| 91 |
+
return data
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f"[LOAD TUTOR CONFIG ERROR] Failed reading {path}: {e}")
|
| 94 |
+
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# Singleton instance helper
|
| 99 |
+
tenant_service = TenantService()
|
backend/app/services/tool_executor.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Config-driven tool registry for the ArunCore agent.
|
| 2 |
+
|
| 3 |
+
Every tool the LLM may call is defined here and exposed through `ToolExecutor`.
|
| 4 |
+
A tenant's `enabled_tools` array (agent.json) simply selects which tools get
|
| 5 |
+
bound into the LLM execution loop β no Python edits required to add a client.
|
| 6 |
+
"""
|
| 7 |
+
from typing import Dict, Any, List, Optional
|
| 8 |
+
|
| 9 |
+
from langchain_core.tools import tool
|
| 10 |
+
|
| 11 |
+
from backend.app.services.knowledge_service import knowledge_service
|
| 12 |
+
from backend.app.services.notification_service import schedule_notify_arun
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------- #
|
| 16 |
+
# Legacy placeholder tools (kept for tenant agent.json enabled_tools and #
|
| 17 |
+
# the demo dictionary path; not bound by the core Arun twin agent). #
|
| 18 |
+
# ---------------------------------------------------------------------- #
|
| 19 |
+
@tool
|
| 20 |
+
def search_courses(query: str) -> str:
|
| 21 |
+
"""Search tenant course catalog and curriculum."""
|
| 22 |
+
return f"Retrieved course details for query: '{query}'."
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@tool
|
| 26 |
+
def book_calendar(date: str, topic: str) -> str:
|
| 27 |
+
"""Book a consultation session or mentorship call."""
|
| 28 |
+
return f"Consultation session requested for '{topic}' on {date}."
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@tool
|
| 32 |
+
def faq_lookup(question: str) -> str:
|
| 33 |
+
"""Search tenant FAQ knowledge base."""
|
| 34 |
+
return f"Retrieved verified FAQ answers for: '{question}'."
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------- #
|
| 38 |
+
# ArunCore real agent tools #
|
| 39 |
+
# ---------------------------------------------------------------------- #
|
| 40 |
+
@tool
|
| 41 |
+
def search_arun_knowledge(query: str) -> str:
|
| 42 |
+
"""Search Arun's local knowledge base for information about his projects, architecture, philosophy, and background."""
|
| 43 |
+
return knowledge_service.search(query)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@tool
|
| 47 |
+
def get_github_live_data(username: str = "neural-arun") -> str:
|
| 48 |
+
"""Fetch live GitHub repository data and recent commits for Arun Yadav."""
|
| 49 |
+
return knowledge_service.fetch_live_github(username)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@tool
|
| 53 |
+
def notify_arun(category: str, user_input: str, user_metadata_json: str = "") -> str:
|
| 54 |
+
"""Send an instant Telegram alert to Arun's phone ONLY when a visitor explicitly asks to hire, consult, or contact Arun (LEAD), asks an unknown technical question (UNKNOWN_QUESTION), or requests urgent assistance (URGENT). Do NOT call this tool for general questions or identity questions like 'who are you'."""
|
| 55 |
+
schedule_notify_arun(category, user_input, user_metadata_json)
|
| 56 |
+
return (
|
| 57 |
+
f"Successfully sent Telegram alert to Arun's phone (Category: {category.upper()}).\n"
|
| 58 |
+
"YOU MUST NOW OUTPUT ARUN'S DIRECT CONTACT DETAILS IN BULLET POINTS:\n"
|
| 59 |
+
"- π Phone: +91 8881109193\n"
|
| 60 |
+
"- π¬ WhatsApp: https://wa.me/918881109193\n"
|
| 61 |
+
"- βοΈ Email: neural.arun.dev@gmail.com\n"
|
| 62 |
+
"- πΌ LinkedIn: https://www.linkedin.com/in/arun-yadav-768052368\n"
|
| 63 |
+
"- π GitHub: https://github.com/neural-arun"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class ToolExecutor:
|
| 68 |
+
"""Dynamic, config-driven tool registry bound into the LLM loop."""
|
| 69 |
+
|
| 70 |
+
DEFAULT_TOOLS = ["search_arun_knowledge", "get_github_live_data", "notify_arun"]
|
| 71 |
+
|
| 72 |
+
AVAILABLE_TOOLS = {
|
| 73 |
+
"search_courses": search_courses,
|
| 74 |
+
"book_calendar": book_calendar,
|
| 75 |
+
"faq_lookup": faq_lookup,
|
| 76 |
+
"search_arun_knowledge": search_arun_knowledge,
|
| 77 |
+
"get_github_live_data": get_github_live_data,
|
| 78 |
+
"notify_arun": notify_arun,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
@classmethod
|
| 82 |
+
def get_enabled_tools(cls, enabled_tool_names: Optional[List[str]]) -> List[Any]:
|
| 83 |
+
names = enabled_tool_names or cls.DEFAULT_TOOLS
|
| 84 |
+
tools: List[Any] = []
|
| 85 |
+
for name in names:
|
| 86 |
+
if name in cls.AVAILABLE_TOOLS:
|
| 87 |
+
tools.append(cls.AVAILABLE_TOOLS[name])
|
| 88 |
+
return tools
|
| 89 |
+
|
| 90 |
+
@classmethod
|
| 91 |
+
def get_tool_map(cls, enabled_tool_names: Optional[List[str]]) -> Dict[str, Any]:
|
| 92 |
+
return {t.name: t for t in cls.get_enabled_tools(enabled_tool_names)}
|
backend/app/services/voice_service.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import io
|
| 3 |
+
import requests
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class VoiceService:
|
| 8 |
+
@staticmethod
|
| 9 |
+
def generate_tts_audio(text: str, voice: str = "alloy") -> bytes:
|
| 10 |
+
openai_key = os.getenv("OPENAI_API_KEY")
|
| 11 |
+
if not openai_key:
|
| 12 |
+
raise ValueError("OPENAI_API_KEY environment variable missing.")
|
| 13 |
+
|
| 14 |
+
clean_text = (
|
| 15 |
+
text.replace("*", "")
|
| 16 |
+
.replace("#", "")
|
| 17 |
+
.replace("`", "")
|
| 18 |
+
.replace("\n", " ")
|
| 19 |
+
[:1000]
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
res = requests.post(
|
| 23 |
+
"https://api.openai.com/v1/audio/speech",
|
| 24 |
+
headers={
|
| 25 |
+
"Authorization": f"Bearer {openai_key}",
|
| 26 |
+
"Content-Type": "application/json",
|
| 27 |
+
},
|
| 28 |
+
json={
|
| 29 |
+
"model": "tts-1",
|
| 30 |
+
"input": clean_text,
|
| 31 |
+
"voice": voice or "alloy",
|
| 32 |
+
},
|
| 33 |
+
timeout=15,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
if res.status_code == 200:
|
| 37 |
+
return res.content
|
| 38 |
+
else:
|
| 39 |
+
raise RuntimeError(f"OpenAI TTS API error {res.status_code}: {res.text}")
|
data/HOW_TO_UPDATE_DATA.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# How to Update ArunCore Knowledge Data
|
| 2 |
+
|
| 3 |
+
ArunCore uses a modular single-source-of-truth structure for your project data.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## π Data Structure Overview
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
data/
|
| 11 |
+
βββ static/
|
| 12 |
+
β βββ public_profile.md # Your core identity, principles, vision & loop
|
| 13 |
+
β βββ rules_of_engagement.md # LLM guidelines & zero-hallucination rules
|
| 14 |
+
β
|
| 15 |
+
βββ github/ # ONE clean folder per GitHub project
|
| 16 |
+
βββ <project_name>/
|
| 17 |
+
β βββ README.md # Project overview, tech stack, & direct GitHub URL link
|
| 18 |
+
β βββ metadata.json # Structured metadata (URL, stack, stars, topics)
|
| 19 |
+
βββ ...
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## π How to Add or Update a Project
|
| 25 |
+
|
| 26 |
+
### Option A: Automatic GitHub Sync (Recommended)
|
| 27 |
+
Run the automated sync script to pull your latest READMEs and GitHub URLs directly from your GitHub profile (`neural-arun`):
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
python scripts/sync_github_data.py
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
### Option B: Manual Add
|
| 34 |
+
1. Create a new folder under `data/github/<your_repo_name>/`.
|
| 35 |
+
2. Place your project `README.md` and `metadata.json` inside it.
|
| 36 |
+
3. Make sure `README.md` starts with a link header:
|
| 37 |
+
```markdown
|
| 38 |
+
> **GitHub Repository:** [https://github.com/neural-arun/your_repo_name](https://github.com/neural-arun/your_repo_name)
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
## π Re-Ingest into Vector DB
|
| 44 |
+
|
| 45 |
+
After adding or modifying files in `data/`:
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
python core/ingest.py
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
`ingest.py` incrementally updates ChromaDB so your agent instantly gains access to the new knowledge.
|
data/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# π ArunCore Data Directory (`data/`)
|
| 2 |
+
|
| 3 |
+
This directory contains the knowledge base, static profile documents, raw backgrounds, and GitHub repository README files used for retrieval-augmented generation.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## π Data Subdirectories & Files
|
| 8 |
+
|
| 9 |
+
```text
|
| 10 |
+
data/
|
| 11 |
+
βββ github/ # 21 subdirectories containing raw README.md files for each repo
|
| 12 |
+
β βββ aruncore/ # ArunCore RAG Engine README
|
| 13 |
+
β βββ legal_RAG_system/ # Legal RAG System README
|
| 14 |
+
β βββ med_coach/ # MedCoach Clinical Reasoning README
|
| 15 |
+
β βββ neet-bot/ # NEET Medical Bot README
|
| 16 |
+
β βββ real_state_listing_scraper/ # 99acres Scraper README
|
| 17 |
+
β βββ ... (21 total public repositories)
|
| 18 |
+
βββ linkedin/
|
| 19 |
+
β βββ posts.md # Scraped public LinkedIn posts & technical insights
|
| 20 |
+
β βββ profile_summary.md # Public LinkedIn profile summary
|
| 21 |
+
βββ raw/
|
| 22 |
+
β βββ personal_background.md # Deeper origin story, NEET pivot, JEE journey, & working style
|
| 23 |
+
β βββ unknown_questions.json # Verified Q&A store for active learning alerts
|
| 24 |
+
βββ static/
|
| 25 |
+
βββ public_profile.md # Master public technical profile & architecture specs
|
| 26 |
+
βββ rules_of_engagement.md # Steering rules, search mandates, & guardrails
|
| 27 |
+
βββ voice_persona.md # Custom sharp & witty voice persona definition
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## π Auto-Syncing Data
|
| 33 |
+
|
| 34 |
+
- **Sync GitHub READMEs**: `python3 scripts/sync_github.py`
|
| 35 |
+
- **Sync LinkedIn Posts**: `python3 scripts/sync_linkedin.py`
|
| 36 |
+
- **1-Click Master Sync**: `python3 scripts/sync_all.py`
|
| 37 |
+
- **Vector Ingestion**: `python3 scripts/ingest.py`
|
data/github/01_manage_patient_task/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
project_name: 01_manage_patient_task
|
| 3 |
+
github_url: https://github.com/neural-arun/01_manage_patient_task
|
| 4 |
+
language: Python
|
| 5 |
+
stars: 0
|
| 6 |
+
topics: [None]
|
| 7 |
+
updated_at: 2026-06-28T11:34:30Z
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# 01_manage_patient_task
|
| 11 |
+
|
| 12 |
+
> **GitHub Repository:** [https://github.com/neural-arun/01_manage_patient_task](https://github.com/neural-arun/01_manage_patient_task)
|
| 13 |
+
> **Primary Language:** Python | **Stars:** 0 | **Forks:** 0
|
| 14 |
+
> **Description:** No description provided.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
# Patient Task Management API
|
| 19 |
+
|
| 20 |
+
**Build 1** of the FastAPI for Healthcare + Medical Education Systems roadmap.
|
| 21 |
+
|
| 22 |
+
A CRUD API for managing patient tasks β medication reminders, daily activities, and completion tracking β built with FastAPI.
|
| 23 |
+
|
| 24 |
+
## Features
|
| 25 |
+
|
| 26 |
+
- Create a patient task
|
| 27 |
+
- List all tasks (with optional status filtering)
|
| 28 |
+
- Get a single task by ID
|
| 29 |
+
- Update a task
|
| 30 |
+
- Mark a task as complete
|
| 31 |
+
- Delete a task
|
| 32 |
+
|
| 33 |
+
## Tech Stack
|
| 34 |
+
|
| 35 |
+
- **FastAPI** β web framework
|
| 36 |
+
- **Uvicorn** β ASGI server
|
| 37 |
+
- **Pydantic** β request/response validation
|
| 38 |
+
- **Python 3.10+**
|
| 39 |
+
|
| 40 |
+
## Project Structure
|
| 41 |
+
|
| 42 |
+
```
|
| 43 |
+
01_manage_patient_task/
|
| 44 |
+
βββ main.py # App entry point & route definitions
|
| 45 |
+
βββ models.py # Pydantic schemas
|
| 46 |
+
βββ notes/ # Study notes (learning reference)
|
| 47 |
+
βββ requirements.txt # Dependencies
|
| 48 |
+
βββ README.md
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
## Setup
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
python -m venv .venv
|
| 55 |
+
source .venv/bin/activate
|
| 56 |
+
pip install -r requirements.txt
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## Run
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
uvicorn main:app --reload
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
- API: `http://127.0.0.1:8000`
|
| 66 |
+
- Docs: `http://127.0.0.1:8000/docs`
|
| 67 |
+
- ReDoc: `http://127.0.0.1:8000/redoc`
|
| 68 |
+
|
| 69 |
+
## API Endpoints
|
| 70 |
+
|
| 71 |
+
| Method | Endpoint | Description |
|
| 72 |
+
|----------|--------------------------|--------------------|
|
| 73 |
+
| `POST` | `/tasks` | Create a task |
|
| 74 |
+
| `GET` | `/tasks` | List tasks |
|
| 75 |
+
| `GET` | `/tasks/{task_id}` | Get a task |
|
| 76 |
+
| `PUT` | `/tasks/{task_id}` | Update a task |
|
| 77 |
+
| `PATCH` | `/tasks/{task_id}/complete` | Mark complete |
|
| 78 |
+
| `DELETE` | `/tasks/{task_id}` | Delete a task |
|
| 79 |
+
|
| 80 |
+
## Topics Covered
|
| 81 |
+
|
| 82 |
+
- FastAPI fundamentals & ASGI
|
| 83 |
+
- Routing (GET, POST, PUT, PATCH, DELETE)
|
| 84 |
+
- Path & query parameters
|
| 85 |
+
- Request body & Pydantic validation
|
| 86 |
+
- Response models & status codes
|
| 87 |
+
- Error handling with HTTPException
|
data/github/01_manage_patient_task/metadata.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "01_manage_patient_task",
|
| 3 |
+
"github_url": "https://github.com/neural-arun/01_manage_patient_task",
|
| 4 |
+
"description": "No description provided.",
|
| 5 |
+
"language": "Python",
|
| 6 |
+
"stars": 0,
|
| 7 |
+
"forks": 0,
|
| 8 |
+
"topics": [],
|
| 9 |
+
"updated_at": "2026-06-28T11:34:30Z"
|
| 10 |
+
}
|
data/github/Agentic_AI_Projects/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
project_name: Agentic_AI_Projects
|
| 3 |
+
github_url: https://github.com/neural-arun/Agentic_AI_Projects
|
| 4 |
+
language: Python
|
| 5 |
+
stars: 0
|
| 6 |
+
topics: [None]
|
| 7 |
+
updated_at: 2026-03-22T07:20:56Z
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Agentic_AI_Projects
|
| 11 |
+
|
| 12 |
+
> **GitHub Repository:** [https://github.com/neural-arun/Agentic_AI_Projects](https://github.com/neural-arun/Agentic_AI_Projects)
|
| 13 |
+
> **Primary Language:** Python | **Stars:** 0 | **Forks:** 0
|
| 14 |
+
> **Description:** No description provided.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
*No README.md file found in this repository on GitHub.*
|
| 19 |
+
|
| 20 |
+
## Overview
|
| 21 |
+
No description provided.
|
data/github/Agentic_AI_Projects/metadata.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Agentic_AI_Projects",
|
| 3 |
+
"github_url": "https://github.com/neural-arun/Agentic_AI_Projects",
|
| 4 |
+
"description": "No description provided.",
|
| 5 |
+
"language": "Python",
|
| 6 |
+
"stars": 0,
|
| 7 |
+
"forks": 0,
|
| 8 |
+
"topics": [],
|
| 9 |
+
"updated_at": "2026-03-22T07:20:56Z"
|
| 10 |
+
}
|
data/github/ArunCore/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ArunCore β Production Personal AI Assistant & Portfolio
|
| 2 |
+
|
| 3 |
+
> **GitHub Repository:** [https://github.com/neural-arun/ArunCore](https://github.com/neural-arun/ArunCore)
|
| 4 |
+
> **Live Web Application:** [https://aruncore.vercel.app](https://aruncore.vercel.app)
|
| 5 |
+
|
| 6 |
+
ArunCore is an agentic, stateful personal AI twin built for **Arun Yadav** (AI Systems Architect specializing in Healthcare & Education).
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## Key Features
|
| 11 |
+
- **100% Automated Telegram Alerts**: Every visitor interaction automatically sends a notification to Arun's phone.
|
| 12 |
+
- **1-Click Magic Link 3-Way Real Human Takeover**: Enables Arun to join ongoing web chat sessions live from Telegram via a 1-click magic link.
|
| 13 |
+
- **Vercel Serverless Egress Relay**: Routes Telegram API traffic through Vercel serverless routes (`/api/telegram`) to eliminate firewall timeouts.
|
| 14 |
+
- **Active Learning Vector Store**: Stores verified human answers from Telegram replies into `data/raw/unknown_questions.json` and re-indexes ChromaDB.
|
| 15 |
+
- **Zero-Hallucination RAG**: Dense vector retrieval + BM25 keyword search + Cohere V3 Reranker.
|
| 16 |
+
- **Dynamic Language Rules**: Responds in 100% articulate English for English queries, matching user tone cleanly.
|