Upload 12 files
Browse files- README.md +10 -12
- app.py +3 -3
- engine-ddo/.gitignore +6 -0
- engine-ddo/Dockerfile +20 -0
- engine-ddo/README.md +21 -0
- engine-ddo/db.py +20 -0
- engine-ddo/main.py +127 -0
- engine-ddo/models.py +40 -0
- engine-ddo/requirements.txt +8 -0
- engine-ddo/schemas.py +52 -0
- engine-ddo/services.py +131 -0
- requirements.txt +0 -2
README.md
CHANGED
|
@@ -1,26 +1,24 @@
|
|
| 1 |
---
|
| 2 |
-
title: veureu-
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: "4.44.1"
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
-
#
|
| 13 |
|
| 14 |
## Endpoints
|
| 15 |
-
- **`/api/predict`** (Gradio): entrada `[
|
| 16 |
➜ Este es el endpoint que usa el Space **engine**.
|
| 17 |
-
- **`/api/
|
| 18 |
|
| 19 |
### Variables de entorno
|
| 20 |
-
- `MODEL_ID` (opcional): por defecto `BSC-LT/salamandra-7b-
|
| 21 |
-
Puedes apuntar a `BSC-LT/salamandra-7b-instruct` si prefieres.
|
| 22 |
|
| 23 |
### Notas
|
| 24 |
-
- El modelo
|
| 25 |
-
|
| 26 |
-
Puedes desactivar la ejecución poniendo `EXECUTE_TOOLS=False` en `app.py`.
|
|
|
|
| 1 |
---
|
| 2 |
+
title: veureu-schat
|
| 3 |
+
emoji: 💬
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: "4.44.1"
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# 💬 veureu-schat (Salamandra-7B-Instruct · ZeroGPU)
|
| 13 |
|
| 14 |
## Endpoints
|
| 15 |
+
- **`/api/predict`** (Gradio): entrada `["<prompt>"]` → salida `"<texto>"`.
|
| 16 |
➜ Este es el endpoint que usa el Space **engine**.
|
| 17 |
+
- **`/api/generate`** (Gradio): entrada `[prompt, system, max_new_tokens, temperature, top_p]` → salida `"<texto>"`.
|
| 18 |
|
| 19 |
### Variables de entorno
|
| 20 |
+
- `MODEL_ID` (opcional): por defecto `BSC-LT/salamandra-7b-instruct`.
|
|
|
|
| 21 |
|
| 22 |
### Notas
|
| 23 |
+
- El modelo usa `chat_template` si existe; si no, se compone un prompt clásico con bloque `system`.
|
| 24 |
+
- GPU: se activa con `@spaces.GPU` automáticamente (ZeroGPU).
|
|
|
app.py
CHANGED
|
@@ -103,11 +103,11 @@ with gr.Blocks(title="Salamandra 7B Instruct · ZeroGPU") as demo:
|
|
| 103 |
with gr.Column(scale=1):
|
| 104 |
out = gr.Textbox(label="Respuesta", lines=18)
|
| 105 |
|
| 106 |
-
btn.click(generate_advanced, [in_prompt, in_system, max_new, temp, top_p], out, api_name="generate")
|
| 107 |
|
| 108 |
# Endpoint minimalista compatible con el ENGINE (/predict: solo prompt)
|
| 109 |
in_prompt_engine = gr.Textbox(label="Prompt (ENGINE)", value="Di hola en una frase.")
|
| 110 |
out_engine = gr.Textbox(label="Respuesta (ENGINE)")
|
| 111 |
-
gr.Button("Probar /predict").click(predict_for_engine, [in_prompt_engine], out_engine, api_name="predict")
|
| 112 |
|
| 113 |
-
demo.queue(
|
|
|
|
| 103 |
with gr.Column(scale=1):
|
| 104 |
out = gr.Textbox(label="Respuesta", lines=18)
|
| 105 |
|
| 106 |
+
btn.click(generate_advanced, [in_prompt, in_system, max_new, temp, top_p], out, api_name="generate", concurrency_limit=1)
|
| 107 |
|
| 108 |
# Endpoint minimalista compatible con el ENGINE (/predict: solo prompt)
|
| 109 |
in_prompt_engine = gr.Textbox(label="Prompt (ENGINE)", value="Di hola en una frase.")
|
| 110 |
out_engine = gr.Textbox(label="Respuesta (ENGINE)")
|
| 111 |
+
gr.Button("Probar /predict").click(predict_for_engine, [in_prompt_engine], out_engine, api_name="predict", concurrency_limit=1)
|
| 112 |
|
| 113 |
+
demo.queue(max_size=16).launch()
|
engine-ddo/.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.sqlite3
|
| 3 |
+
*.db
|
| 4 |
+
*.pyc
|
| 5 |
+
.env
|
| 6 |
+
/data/
|
engine-ddo/Dockerfile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.9-slim
|
| 3 |
+
|
| 4 |
+
# Set the working directory in the container
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Copy the dependencies file to the working directory
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
|
| 10 |
+
# Install any needed packages specified in requirements.txt
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
# Copy the rest of the application code to the working directory
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Expose the port the app runs on
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
|
| 19 |
+
# Run the application
|
| 20 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
engine-ddo/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Engine (Due Diligence Optimization)
|
| 3 |
+
emoji: ⚙️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# engine-ddo (FastAPI)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
FastAPI backend for Due Diligence Optimization demo. Persists data in SQLite and exposes REST endpoints for:
|
| 14 |
+
- Products: ingest from PDFs, list, detail
|
| 15 |
+
- Customers: list/update
|
| 16 |
+
- Interactions: chat with persistence (conversations/messages)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
## Run locally
|
| 20 |
+
```bash
|
| 21 |
+
uvicorn main:app --host 0.0.0.0 --port 7860 --reload
|
engine-ddo/db.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
DB_PATH = os.environ.get("DDO_DB_PATH", "./ddo.sqlite3")
|
| 7 |
+
engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
|
| 8 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Base(DeclarativeBase):
|
| 12 |
+
pass
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_db():
|
| 16 |
+
db = SessionLocal()
|
| 17 |
+
try:
|
| 18 |
+
yield db
|
| 19 |
+
finally:
|
| 20 |
+
db.close()
|
engine-ddo/main.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from fastapi import FastAPI, UploadFile, File, Depends, HTTPException, Form
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from db import Base, engine, get_db
|
| 6 |
+
from models import Product, CustomerProfile
|
| 7 |
+
from schemas import (
|
| 8 |
+
ProductOut, CustomerOut, CustomerUpdate,
|
| 9 |
+
ChatRequest, ChatResponse, ConversationOut
|
| 10 |
+
)
|
| 11 |
+
from services import extract_and_upsert_products_from_llm, ensure_default_customers, get_or_create_conversation, add_message, get_history
|
| 12 |
+
from typing import List
|
| 13 |
+
import tempfile
|
| 14 |
+
|
| 15 |
+
app = FastAPI(title="engine-ddo", openapi_url="/openapi.json")
|
| 16 |
+
|
| 17 |
+
# CORS for Streamlit UI Space
|
| 18 |
+
app.add_middleware(
|
| 19 |
+
CORSMiddleware,
|
| 20 |
+
allow_origins=["*"],
|
| 21 |
+
allow_credentials=True,
|
| 22 |
+
allow_methods=["*"],
|
| 23 |
+
allow_headers=["*"],
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Initialize DB
|
| 27 |
+
Base.metadata.create_all(bind=engine)
|
| 28 |
+
|
| 29 |
+
@app.get("/health")
|
| 30 |
+
def health():
|
| 31 |
+
return {"status": "ok"}
|
| 32 |
+
|
| 33 |
+
# -------- PRODUCTS --------
|
| 34 |
+
@app.post("/products/ingest", response_model=List[ProductOut])
|
| 35 |
+
async def ingest_products(public_offering: UploadFile = File(...), private_notes: UploadFile = File(...), db: Session = Depends(get_db)):
|
| 36 |
+
# Save temp files to pass paths to the service
|
| 37 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as f1:
|
| 38 |
+
f1.write(await public_offering.read())
|
| 39 |
+
public_path = f1.name
|
| 40 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as f2:
|
| 41 |
+
f2.write(await private_notes.read())
|
| 42 |
+
notes_path = f2.name
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
# Call the new service to process PDFs with an LLM
|
| 46 |
+
extract_and_upsert_products_from_llm(db, public_path, notes_path)
|
| 47 |
+
finally:
|
| 48 |
+
# Clean up the temporary files
|
| 49 |
+
os.remove(public_path)
|
| 50 |
+
os.remove(notes_path)
|
| 51 |
+
|
| 52 |
+
# Return all products from the database
|
| 53 |
+
rows = db.query(Product).order_by(Product.name.asc()).all()
|
| 54 |
+
return rows
|
| 55 |
+
|
| 56 |
+
@app.get("/products/list", response_model=List[ProductOut])
|
| 57 |
+
def list_products(db: Session = Depends(get_db)):
|
| 58 |
+
rows = db.query(Product).order_by(Product.name.asc()).all()
|
| 59 |
+
return rows
|
| 60 |
+
|
| 61 |
+
# -------- CUSTOMERS --------
|
| 62 |
+
@app.get("/customers/list", response_model=List[CustomerOut])
|
| 63 |
+
def list_customers(db: Session = Depends(get_db)):
|
| 64 |
+
ensure_default_customers(db)
|
| 65 |
+
rows = db.query(CustomerProfile).order_by(CustomerProfile.name.asc()).all()
|
| 66 |
+
return rows
|
| 67 |
+
|
| 68 |
+
@app.post("/customers/update", response_model=CustomerOut)
|
| 69 |
+
def update_customer(payload: CustomerUpdate, db: Session = Depends(get_db)):
|
| 70 |
+
row = db.query(CustomerProfile).filter_by(name=payload.name).first()
|
| 71 |
+
if not row:
|
| 72 |
+
row = CustomerProfile(name=payload.name)
|
| 73 |
+
db.add(row)
|
| 74 |
+
if payload.attributes is not None:
|
| 75 |
+
row.attributes = payload.attributes
|
| 76 |
+
if payload.wcltv is not None:
|
| 77 |
+
row.wcltv = payload.wcltv
|
| 78 |
+
if payload.n is not None:
|
| 79 |
+
row.n = payload.n
|
| 80 |
+
db.commit()
|
| 81 |
+
db.refresh(row)
|
| 82 |
+
return row
|
| 83 |
+
|
| 84 |
+
# -------- INTERACTIONS (chat) --------
|
| 85 |
+
|
| 86 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
|
| 87 |
+
MODEL = os.environ.get("LLM_MODEL", "gpt-4o-mini")
|
| 88 |
+
|
| 89 |
+
async def llm_reply(system_prompt: str, history: list, user_text: str) -> str:
|
| 90 |
+
"""Return a reply from an external LLM if OPENAI_API_KEY set, else a rule-based stub."""
|
| 91 |
+
if OPENAI_API_KEY:
|
| 92 |
+
try:
|
| 93 |
+
from openai import OpenAI
|
| 94 |
+
client = OpenAI(api_key=OPENAI_API_KEY)
|
| 95 |
+
messages = [{"role": "system", "content": system_prompt}] + history + [{"role": "user", "content": user_text}]
|
| 96 |
+
resp = client.chat.completions.create(model=MODEL, messages=messages)
|
| 97 |
+
return resp.choices[0].message.content.strip()
|
| 98 |
+
except Exception as e:
|
| 99 |
+
return f"[LLM error fallback] I couldn't reach the model ({e}). Let's continue anyway."
|
| 100 |
+
# Fallback deterministic reply for demo
|
| 101 |
+
return "Thanks for the details! Could you share your main need, budget, and timeline? I can match a product for you."
|
| 102 |
+
|
| 103 |
+
@app.post("/interactions/chat", response_model=ChatResponse)
|
| 104 |
+
async def chat(req: ChatRequest, db: Session = Depends(get_db)):
|
| 105 |
+
profile = req.profile_name or "random"
|
| 106 |
+
convo = get_or_create_conversation(db, profile)
|
| 107 |
+
add_message(db, convo.id, sender="customer", text=req.user_text)
|
| 108 |
+
|
| 109 |
+
# Build history for LLM
|
| 110 |
+
hist = []
|
| 111 |
+
for turn in get_history(db, convo.id):
|
| 112 |
+
role = "user" if turn["sender"] == "customer" else "assistant"
|
| 113 |
+
hist.append({"role": role, "content": turn["text"]})
|
| 114 |
+
|
| 115 |
+
system_prompt = (
|
| 116 |
+
"You are a helpful sales assistant. Keep answers short, ask clarifying questions, and reference products generically."
|
| 117 |
+
)
|
| 118 |
+
reply = await llm_reply(system_prompt, hist, req.user_text)
|
| 119 |
+
add_message(db, convo.id, sender="agent", text=reply)
|
| 120 |
+
|
| 121 |
+
return {"reply": reply, "conversation_id": convo.id}
|
| 122 |
+
|
| 123 |
+
@app.get("/interactions/history", response_model=ConversationOut)
|
| 124 |
+
async def history(profile_name: str, db: Session = Depends(get_db)):
|
| 125 |
+
convo = get_or_create_conversation(db, profile_name)
|
| 126 |
+
hist = get_history(db, convo.id)
|
| 127 |
+
return {"id": convo.id, "profile_name": profile_name, "history": hist}
|
engine-ddo/models.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Text, Float, ForeignKey, DateTime
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from db import Base
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Product(Base):
|
| 8 |
+
__tablename__ = "products"
|
| 9 |
+
id = Column(Integer, primary_key=True)
|
| 10 |
+
name = Column(String(200), unique=True, index=True)
|
| 11 |
+
description = Column(Text, nullable=True)
|
| 12 |
+
notes = Column(Text, nullable=True)
|
| 13 |
+
price = Column(Float, nullable=True)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class CustomerProfile(Base):
|
| 17 |
+
__tablename__ = "customer_profiles"
|
| 18 |
+
id = Column(Integer, primary_key=True)
|
| 19 |
+
name = Column(String(200), unique=True, index=True)
|
| 20 |
+
attributes = Column(Text) # JSON or plain text description
|
| 21 |
+
wcltv = Column(Float, default=0.0)
|
| 22 |
+
n = Column(Integer, default=0)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Conversation(Base):
|
| 26 |
+
__tablename__ = "conversations"
|
| 27 |
+
id = Column(Integer, primary_key=True)
|
| 28 |
+
profile_name = Column(String(200), index=True)
|
| 29 |
+
started_at = Column(DateTime, default=datetime.utcnow)
|
| 30 |
+
messages = relationship("Message", back_populates="conversation", cascade="all, delete-orphan")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class Message(Base):
|
| 34 |
+
__tablename__ = "messages"
|
| 35 |
+
id = Column(Integer, primary_key=True)
|
| 36 |
+
conversation_id = Column(Integer, ForeignKey("conversations.id"))
|
| 37 |
+
sender = Column(String(32)) # 'customer' or 'agent'
|
| 38 |
+
text = Column(Text)
|
| 39 |
+
ts = Column(DateTime, default=datetime.utcnow)
|
| 40 |
+
conversation = relationship("Conversation", back_populates="messages")
|
engine-ddo/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn==0.30.1
|
| 3 |
+
pydantic==2.8.2
|
| 4 |
+
SQLAlchemy==2.0.31
|
| 5 |
+
python-multipart==0.0.9
|
| 6 |
+
httpx==0.27.0
|
| 7 |
+
PyPDF2==3.0.1
|
| 8 |
+
openai==1.43.0 # optional; used only if OPENAI_API_KEY provided
|
engine-ddo/schemas.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ProductOut(BaseModel):
|
| 6 |
+
id: int
|
| 7 |
+
name: str
|
| 8 |
+
description: Optional[str] = None
|
| 9 |
+
notes: Optional[str] = None
|
| 10 |
+
price: Optional[float] = None
|
| 11 |
+
|
| 12 |
+
class Config:
|
| 13 |
+
from_attributes = True
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class CustomerOut(BaseModel):
|
| 17 |
+
id: int
|
| 18 |
+
name: str
|
| 19 |
+
attributes: str
|
| 20 |
+
wcltv: float
|
| 21 |
+
n: int
|
| 22 |
+
|
| 23 |
+
class Config:
|
| 24 |
+
from_attributes = True
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class CustomerUpdate(BaseModel):
|
| 28 |
+
name: str
|
| 29 |
+
attributes: Optional[str] = None
|
| 30 |
+
wcltv: Optional[float] = None
|
| 31 |
+
n: Optional[int] = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ChatTurn(BaseModel):
|
| 35 |
+
sender: str = Field(pattern="^(customer|agent)$")
|
| 36 |
+
text: str
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ConversationOut(BaseModel):
|
| 40 |
+
id: int
|
| 41 |
+
profile_name: str
|
| 42 |
+
history: List[ChatTurn]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ChatRequest(BaseModel):
|
| 46 |
+
profile_name: str
|
| 47 |
+
user_text: str
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class ChatResponse(BaseModel):
|
| 51 |
+
reply: str
|
| 52 |
+
conversation_id: int
|
engine-ddo/services.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from models import Product, CustomerProfile, Conversation, Message
|
| 5 |
+
from typing import List
|
| 6 |
+
from PyPDF2 import PdfReader
|
| 7 |
+
|
| 8 |
+
# Product services
|
| 9 |
+
|
| 10 |
+
def _read_pdf_text(file_path: str) -> str:
|
| 11 |
+
try:
|
| 12 |
+
reader = PdfReader(file_path)
|
| 13 |
+
return "\n".join(page.extract_text() or "" for page in reader.pages)
|
| 14 |
+
except Exception:
|
| 15 |
+
return ""
|
| 16 |
+
|
| 17 |
+
def extract_and_upsert_products_from_llm(db: Session, public_pdf_path: str, private_pdf_path: str):
|
| 18 |
+
"""Extracts product info from PDFs using an LLM and saves to DB."""
|
| 19 |
+
public_text = _read_pdf_text(public_pdf_path)
|
| 20 |
+
private_text = _read_pdf_text(private_pdf_path)
|
| 21 |
+
|
| 22 |
+
if not public_text and not private_text:
|
| 23 |
+
# Fallback for demo if PDFs are empty or unreadable
|
| 24 |
+
demo_products = [
|
| 25 |
+
Product(name="Demo Basic", description="Standard features for small teams.", notes="High churn risk.", price=9.0),
|
| 26 |
+
Product(name="Demo Pro", description="Advanced features and priority support.", notes="Stable customer base.", price=39.0),
|
| 27 |
+
Product(name="Demo Enterprise", description="Dedicated support and custom integrations.", notes="Potential for expansion.", price=199.0),
|
| 28 |
+
]
|
| 29 |
+
for p in demo_products:
|
| 30 |
+
db.merge(p)
|
| 31 |
+
db.commit()
|
| 32 |
+
return
|
| 33 |
+
|
| 34 |
+
# Use the LLM call logic from main.py
|
| 35 |
+
from main import llm_reply
|
| 36 |
+
|
| 37 |
+
system_prompt = """
|
| 38 |
+
You are an expert data extractor. Your task is to analyze two documents, a public offering and a private notes document, and extract product information.
|
| 39 |
+
|
| 40 |
+
Respond with a single JSON array of objects. Each object should represent a product and have the following fields:
|
| 41 |
+
- "product": The name of the product.
|
| 42 |
+
- "description": The description from the public offering document.
|
| 43 |
+
- "notes": Internal notes from the private notes document.
|
| 44 |
+
- "price": The price as a numeric value (float), if available.
|
| 45 |
+
|
| 46 |
+
If you find information that does not belong to a specific product, assign it to a product named "general".
|
| 47 |
+
Ensure your output is a valid JSON array.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
user_prompt = f"""
|
| 51 |
+
Here is the content from the public offering document:
|
| 52 |
+
--- PUBLIC OFFERING ---
|
| 53 |
+
{public_text}
|
| 54 |
+
|
| 55 |
+
Here is the content from the private notes document:
|
| 56 |
+
--- PRIVATE NOTES ---
|
| 57 |
+
{private_text}
|
| 58 |
+
|
| 59 |
+
Please extract the product information as a JSON array.
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
# This is a blocking call, so we don't need async here
|
| 63 |
+
import asyncio
|
| 64 |
+
llm_response = asyncio.run(llm_reply(system_prompt, [], user_prompt))
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
# Clean the response to get only the JSON part
|
| 68 |
+
json_str = llm_response[llm_response.find('['):llm_response.rfind(']')+1]
|
| 69 |
+
extracted_data = json.loads(json_str)
|
| 70 |
+
|
| 71 |
+
for item in extracted_data:
|
| 72 |
+
product = Product(
|
| 73 |
+
name=item.get("product", "general"),
|
| 74 |
+
description=item.get("description"),
|
| 75 |
+
notes=item.get("notes"),
|
| 76 |
+
price=float(item["price"]) if item.get("price") else None
|
| 77 |
+
)
|
| 78 |
+
# Use merge to insert or update based on the primary key (name)
|
| 79 |
+
db.merge(product)
|
| 80 |
+
db.commit()
|
| 81 |
+
|
| 82 |
+
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
| 83 |
+
# Handle cases where LLM output is not as expected
|
| 84 |
+
# For demo, we can log the error and maybe insert a placeholder
|
| 85 |
+
print(f"Error parsing LLM response: {e}")
|
| 86 |
+
placeholder = Product(name="Parsing Error", description="Could not parse data from documents.", notes=str(llm_response))
|
| 87 |
+
db.merge(placeholder)
|
| 88 |
+
db.commit()
|
| 89 |
+
|
| 90 |
+
# Customer services
|
| 91 |
+
|
| 92 |
+
def ensure_default_customers(db: Session):
|
| 93 |
+
defaults = [
|
| 94 |
+
("random", "Synthetic profile with randomized traits", 0.0, 0),
|
| 95 |
+
("SMB buyer", "Budget-conscious, quick decisions", 1200.0, 85),
|
| 96 |
+
("Enterprise buyer", "Long sales cycle, security-focused", 24000.0, 12),
|
| 97 |
+
]
|
| 98 |
+
for name, attrs, w, n in defaults:
|
| 99 |
+
row = db.query(CustomerProfile).filter_by(name=name).first()
|
| 100 |
+
if not row:
|
| 101 |
+
db.add(CustomerProfile(name=name, attributes=attrs, wcltv=w, n=n))
|
| 102 |
+
db.commit()
|
| 103 |
+
|
| 104 |
+
# Chat services
|
| 105 |
+
|
| 106 |
+
def get_or_create_conversation(db: Session, profile_name: str) -> Conversation:
|
| 107 |
+
convo = (
|
| 108 |
+
db.query(Conversation)
|
| 109 |
+
.filter_by(profile_name=profile_name)
|
| 110 |
+
.order_by(Conversation.id.desc())
|
| 111 |
+
.first()
|
| 112 |
+
)
|
| 113 |
+
if not convo:
|
| 114 |
+
convo = Conversation(profile_name=profile_name)
|
| 115 |
+
db.add(convo)
|
| 116 |
+
db.commit()
|
| 117 |
+
db.refresh(convo)
|
| 118 |
+
return convo
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def add_message(db: Session, conversation_id: int, sender: str, text: str):
|
| 122 |
+
msg = Message(conversation_id=conversation_id, sender=sender, text=text)
|
| 123 |
+
db.add(msg)
|
| 124 |
+
db.commit()
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def get_history(db: Session, conversation_id: int):
|
| 128 |
+
convo = db.query(Conversation).filter_by(id=conversation_id).first()
|
| 129 |
+
if not convo:
|
| 130 |
+
return []
|
| 131 |
+
return [{"sender": m.sender, "text": m.text} for m in convo.messages]
|
requirements.txt
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
-
gradio>=4.44.1
|
| 2 |
-
spaces>=0.25.0
|
| 3 |
transformers>=4.44.0
|
| 4 |
torch>=2.2
|
| 5 |
accelerate>=0.30.0
|
|
|
|
|
|
|
|
|
|
| 1 |
transformers>=4.44.0
|
| 2 |
torch>=2.2
|
| 3 |
accelerate>=0.30.0
|