Upload 8 files
Browse files- README.md +25 -17
- app.py +71 -0
- model.py +53 -0
- prompts.py +66 -0
- requirements.txt +10 -3
- runtime.txt +1 -0
- salesforce.py +41 -0
- schema.py +49 -0
README.md
CHANGED
|
@@ -1,20 +1,28 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Salesforce Generate Pre Call Summary
|
| 3 |
-
emoji: 🚀
|
| 4 |
-
colorFrom: red
|
| 5 |
-
colorTo: red
|
| 6 |
-
sdk: docker
|
| 7 |
-
app_port: 8501
|
| 8 |
-
tags:
|
| 9 |
-
- streamlit
|
| 10 |
-
pinned: false
|
| 11 |
-
short_description: To generate pre call summary for agent
|
| 12 |
-
license: apache-2.0
|
| 13 |
-
---
|
| 14 |
|
| 15 |
-
#
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
|
| 20 |
-
forums](https://discuss.streamlit.io).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
|
| 2 |
+
# Pre‑Call Summary Generator (Salesforce → Hugging Face Space)
|
| 3 |
|
| 4 |
+
A Streamlit app that:
|
| 5 |
+
1) Accepts Salesforce credentials (instance URL + access token).
|
| 6 |
+
2) Pulls Account, Opportunities, Cases via REST/SOQL.
|
| 7 |
+
3) Calls a summarization **model** (Hugging Face Inference API by default).
|
| 8 |
+
4) Shows a structured **pre‑call summary** and (optionally) writes it back to Salesforce as a Note.
|
| 9 |
+
|
| 10 |
+
## How to run on Hugging Face Spaces
|
| 11 |
+
|
| 12 |
+
1. Create a new Space → **SDK: Streamlit** → **Private** (recommended for prototypes).
|
| 13 |
+
2. Upload this repo's files.
|
| 14 |
+
3. In the Space → **Settings → Repository secrets**, add your tokens:
|
| 15 |
+
- `HF_TOKEN` : (required) A Hugging Face token with Inference API access.
|
| 16 |
+
4. (Optional) For testing locally, you can run `streamlit run app.py`.
|
| 17 |
+
|
| 18 |
+
## Security Notes
|
| 19 |
+
- Do **not** log or persist Salesforce tokens. This app keeps everything in memory only for the session.
|
| 20 |
+
- For production, prefer Salesforce OAuth Web Server flow instead of pasting tokens.
|
| 21 |
+
- Scope the SOQL fields and follow FLS/sharing best practices in your org.
|
| 22 |
+
|
| 23 |
+
## Usage
|
| 24 |
+
- Enter `instance_url` (e.g., `https://yourDomain.my.salesforce.com`) and a valid **Access Token**.
|
| 25 |
+
- Provide an `Account Id` (e.g., starts with `001...`).
|
| 26 |
+
- Click **Generate Summary**.
|
| 27 |
+
- Optionally check **"Write to Salesforce as Note"** to save the JSON summary back.
|
| 28 |
|
|
|
|
|
|
app.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import streamlit as st
|
| 3 |
+
import json
|
| 4 |
+
from salesforce import get_context, create_note
|
| 5 |
+
from model import PreCallSummaryModel
|
| 6 |
+
from schema import PreCallSummary
|
| 7 |
+
|
| 8 |
+
st.set_page_config(page_title="Pre‑Call Summary Generator", layout="wide")
|
| 9 |
+
|
| 10 |
+
st.title("Pre‑Call Summary Generator (Salesforce → HF Space)")
|
| 11 |
+
|
| 12 |
+
with st.expander("Instructions", expanded=False):
|
| 13 |
+
st.markdown("""
|
| 14 |
+
1. Enter your Salesforce **Instance URL** (e.g., `https://xxx.my.salesforce.com`).
|
| 15 |
+
2. Provide a short‑lived **Access Token** (from a Connected App / OAuth flow).
|
| 16 |
+
3. Provide an **Account Id** (`001...`).
|
| 17 |
+
4. Click **Generate Summary**. Optionally save it as a **Note** in Salesforce.
|
| 18 |
+
**Security:** tokens are used only in memory for this session and are not logged.
|
| 19 |
+
""")
|
| 20 |
+
|
| 21 |
+
inst = st.text_input("Salesforce Instance URL", placeholder="https://yourDomain.my.salesforce.com")
|
| 22 |
+
token = st.text_input("Salesforce Access Token", type="password", placeholder="Paste a short‑lived token")
|
| 23 |
+
acct_id = st.text_input("Account Id", placeholder="001xxxxxxxxxxxx")
|
| 24 |
+
|
| 25 |
+
colA, colB, colC = st.columns([1,1,1])
|
| 26 |
+
with colA:
|
| 27 |
+
opp_n = st.number_input("Max Opportunities", min_value=0, max_value=20, value=5, step=1)
|
| 28 |
+
with colB:
|
| 29 |
+
case_n = st.number_input("Max Cases", min_value=0, max_value=20, value=5, step=1)
|
| 30 |
+
with colC:
|
| 31 |
+
task_n = st.number_input("Max Tasks", min_value=0, max_value=50, value=10, step=1)
|
| 32 |
+
|
| 33 |
+
push_back = st.checkbox("Write to Salesforce as Note after generation", value=False)
|
| 34 |
+
generate = st.button("Generate Summary", type="primary")
|
| 35 |
+
|
| 36 |
+
if generate:
|
| 37 |
+
if not inst or not token or not acct_id:
|
| 38 |
+
st.error("Please provide instance URL, access token, and account id.")
|
| 39 |
+
st.stop()
|
| 40 |
+
|
| 41 |
+
with st.spinner("Fetching Salesforce context..."):
|
| 42 |
+
try:
|
| 43 |
+
context = get_context(inst, token, acct_id, opp_n, case_n, task_n)
|
| 44 |
+
except Exception as e:
|
| 45 |
+
st.error(f"Salesforce API error: {e}")
|
| 46 |
+
st.stop()
|
| 47 |
+
|
| 48 |
+
st.subheader("Fetched Context (trimmed)")
|
| 49 |
+
st.json(context)
|
| 50 |
+
|
| 51 |
+
with st.spinner("Calling model..."):
|
| 52 |
+
model = PreCallSummaryModel()
|
| 53 |
+
try:
|
| 54 |
+
summary: PreCallSummary = model.generate(context)
|
| 55 |
+
except Exception as e:
|
| 56 |
+
st.error(f"Model error: {e}")
|
| 57 |
+
st.stop()
|
| 58 |
+
|
| 59 |
+
st.subheader("Pre‑Call Summary (JSON)")
|
| 60 |
+
st.json(json.loads(summary.model_dump_json()))
|
| 61 |
+
|
| 62 |
+
if push_back:
|
| 63 |
+
with st.spinner("Writing Note to Salesforce..."):
|
| 64 |
+
try:
|
| 65 |
+
note_id = create_note(inst, token, acct_id, "Pre‑Call Summary", summary.model_dump_json(indent=2))
|
| 66 |
+
st.success(f"Saved Note Id: {note_id}")
|
| 67 |
+
except Exception as e:
|
| 68 |
+
st.error(f"Failed to save Note: {e}")
|
| 69 |
+
|
| 70 |
+
st.download_button("Download JSON", data=summary.model_dump_json(indent=2),
|
| 71 |
+
file_name=f"precall_summary_{acct_id}.json", mime="application/json")
|
model.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import json, orjson, json5, requests, os
|
| 3 |
+
from typing import Dict, Any
|
| 4 |
+
from schema import ensure_schema, PreCallSummary
|
| 5 |
+
from prompts import build_prompt
|
| 6 |
+
|
| 7 |
+
class PreCallSummaryModel:
|
| 8 |
+
def __init__(self, inference_model_url: str | None = None, hf_token: str | None = None):
|
| 9 |
+
# If inference_model_url is None, we'll default to FLAN-T5-base via Inference API
|
| 10 |
+
self.inference_model_url = inference_model_url or "https://api-inference.huggingface.co/models/google/flan-t5-base"
|
| 11 |
+
self.hf_token = hf_token or os.environ.get("HF_TOKEN")
|
| 12 |
+
|
| 13 |
+
def _call_inference_api(self, prompt: str) -> str:
|
| 14 |
+
headers = {"Authorization": f"Bearer {self.hf_token}"} if self.hf_token else {}
|
| 15 |
+
payload = {"inputs": prompt, "parameters": {"max_new_tokens": 512, "return_full_text": False}}
|
| 16 |
+
r = requests.post(self.inference_model_url, headers=headers, json=payload, timeout=120)
|
| 17 |
+
r.raise_for_status()
|
| 18 |
+
data = r.json()
|
| 19 |
+
if isinstance(data, list) and data and "generated_text" in data[0]:
|
| 20 |
+
return data[0]["generated_text"]
|
| 21 |
+
if isinstance(data, dict) and "generated_text" in data:
|
| 22 |
+
return data["generated_text"]
|
| 23 |
+
# Fallback: some endpoints return plain text
|
| 24 |
+
return str(data)
|
| 25 |
+
|
| 26 |
+
def _parse_json(self, s: str) -> Dict[str, Any]:
|
| 27 |
+
# Try strict json, then orjson, then json5, then best-effort substring extraction
|
| 28 |
+
try:
|
| 29 |
+
return json.loads(s)
|
| 30 |
+
except Exception:
|
| 31 |
+
pass
|
| 32 |
+
try:
|
| 33 |
+
return orjson.loads(s)
|
| 34 |
+
except Exception:
|
| 35 |
+
pass
|
| 36 |
+
try:
|
| 37 |
+
return json5.loads(s)
|
| 38 |
+
except Exception:
|
| 39 |
+
pass
|
| 40 |
+
# naive extraction between first { and last }
|
| 41 |
+
try:
|
| 42 |
+
start, end = s.find("{"), s.rfind("}")
|
| 43 |
+
if start != -1 and end != -1 and end > start:
|
| 44 |
+
return json.loads(s[start:end+1])
|
| 45 |
+
except Exception:
|
| 46 |
+
pass
|
| 47 |
+
return {}
|
| 48 |
+
|
| 49 |
+
def generate(self, context: Dict[str, Any]) -> PreCallSummary:
|
| 50 |
+
prompt = build_prompt(json.dumps(context, ensure_ascii=False, default=str))
|
| 51 |
+
raw = self._call_inference_api(prompt)
|
| 52 |
+
parsed = self._parse_json(raw)
|
| 53 |
+
return ensure_schema(parsed)
|
prompts.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
SCHEMA_TEXT = """
|
| 3 |
+
Return JSON with EXACTLY these keys:
|
| 4 |
+
{
|
| 5 |
+
"client_overview": str,
|
| 6 |
+
"relationship_timeline": [
|
| 7 |
+
{"date":"YYYY-MM-DD","event":"...", "source":"Case|Opportunity|Task|Activity|Other"}
|
| 8 |
+
],
|
| 9 |
+
"open_items": [str],
|
| 10 |
+
"opportunities": [
|
| 11 |
+
{"name":"...", "stage":"...", "amount": number, "closeDate":"YYYY-MM-DD or null", "nextStep":"..."}
|
| 12 |
+
],
|
| 13 |
+
"risks_flags": [str],
|
| 14 |
+
"recommendations": [str],
|
| 15 |
+
"pre_call_questions": [str],
|
| 16 |
+
"sources": [str]
|
| 17 |
+
}
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
FEW_SHOT = [
|
| 21 |
+
{
|
| 22 |
+
"context": {
|
| 23 |
+
"account": {"Id":"001X","Name":"Acme Corp","Industry":"Manufacturing","Type":"Customer-Direct","Rating":"Hot","AnnualRevenue": 45000000},
|
| 24 |
+
"opportunities": [
|
| 25 |
+
{"Id":"006A","Name":"Q4 Renewal","StageName":"Negotiation","Amount": 2000000, "CloseDate":"2025-09-30", "NextStep":"Legal review"},
|
| 26 |
+
{"Id":"006B","Name":"Cross-sell Treasury","StageName":"Qualification","Amount": 500000, "CloseDate":"2025-10-15", "NextStep":"Intro call"}
|
| 27 |
+
],
|
| 28 |
+
"cases": [
|
| 29 |
+
{"Id":"500C","CaseNumber":"000123","Subject":"Payment delay","Status":"In Progress","Priority":"High","Origin":"Phone","LastModifiedDate":"2025-08-01T12:00:00Z"}
|
| 30 |
+
],
|
| 31 |
+
"activities": [
|
| 32 |
+
{"Id":"00T1","Subject":"Quarterly check-in","ActivityDate":"2025-07-15","Status":"Completed","Owner":{"Alias":"jdoe"}}
|
| 33 |
+
]
|
| 34 |
+
},
|
| 35 |
+
"output": {
|
| 36 |
+
"client_overview": "Acme Corp (Manufacturing, Customer-Direct). Est. revenue $45M. Relationship rated Hot.",
|
| 37 |
+
"relationship_timeline": [
|
| 38 |
+
{"date":"2025-08-01","event":"High-priority case: Payment delay is in progress.","source":"Case"},
|
| 39 |
+
{"date":"2025-07-15","event":"Completed quarterly check-in.","source":"Activity"}
|
| 40 |
+
],
|
| 41 |
+
"open_items": ["Follow up on payment delay case"],
|
| 42 |
+
"opportunities": [
|
| 43 |
+
{"name":"Q4 Renewal","stage":"Negotiation","amount":2000000,"closeDate":"2025-09-30","nextStep":"Legal review"},
|
| 44 |
+
{"name":"Cross-sell Treasury","stage":"Qualification","amount":500000,"closeDate":"2025-10-15","nextStep":"Intro call"}
|
| 45 |
+
],
|
| 46 |
+
"risks_flags": ["Open high-priority service issue may affect renewal"],
|
| 47 |
+
"recommendations": ["Confirm payment issue resolution","Schedule renewal legal review","Propose treasury intro"],
|
| 48 |
+
"pre_call_questions": ["Who is sponsor for renewal?","Any blockers from finance?","Treasury appetite and timeline?"],
|
| 49 |
+
"sources": ["Account:001X","Opportunity:006A","Opportunity:006B","Case:500C","Task:00T1"]
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
def build_prompt(context_json: str) -> str:
|
| 55 |
+
# Guidance prompt for instruction-tuned models like FLAN-T5
|
| 56 |
+
parts = []
|
| 57 |
+
parts.append("You are a banking relationship assistant. Summarize pre-call context for the given Account.")
|
| 58 |
+
parts.append("Be concise and factual. If data is missing, say 'Unknown' briefly. Output STRICT JSON only.")
|
| 59 |
+
parts.append(SCHEMA_TEXT)
|
| 60 |
+
parts.append("Here is a worked example:")
|
| 61 |
+
parts.append("Context: " + str(FEW_SHOT[0]["context"]))
|
| 62 |
+
parts.append("Output JSON: " + str(FEW_SHOT[0]["output"]))
|
| 63 |
+
parts.append("Now produce output for this Context:")
|
| 64 |
+
parts.append(context_json)
|
| 65 |
+
parts.append("Output JSON only:")
|
| 66 |
+
return "\n\n".join(parts)
|
requirements.txt
CHANGED
|
@@ -1,3 +1,10 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
streamlit==1.37.0
|
| 3 |
+
requests==2.32.3
|
| 4 |
+
pydantic==2.8.2
|
| 5 |
+
orjson==3.10.7
|
| 6 |
+
json5==0.9.25
|
| 7 |
+
# If you later want local models instead of Inference API, uncomment below
|
| 8 |
+
# transformers==4.43.3
|
| 9 |
+
# torch>=2.2.0
|
| 10 |
+
# sentencepiece==0.2.0
|
runtime.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
python-3.10.14
|
salesforce.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import requests
|
| 3 |
+
from typing import Dict, Any
|
| 4 |
+
|
| 5 |
+
def sf_query(instance_url: str, access_token: str, soql: str) -> list[dict]:
|
| 6 |
+
api = f"{instance_url}/services/data/v61.0/query"
|
| 7 |
+
res = requests.get(api, params={"q": soql},
|
| 8 |
+
headers={"Authorization": f"Bearer {access_token}"})
|
| 9 |
+
res.raise_for_status()
|
| 10 |
+
return res.json().get("records", [])
|
| 11 |
+
|
| 12 |
+
def get_context(instance_url: str, access_token: str, account_id: str,
|
| 13 |
+
limit_opps: int = 5, limit_cases: int = 5, limit_tasks: int = 10) -> Dict[str, Any]:
|
| 14 |
+
acct = sf_query(instance_url, access_token,
|
| 15 |
+
f"SELECT Id, Name, Industry, Type, Rating, AnnualRevenue, Owner.Alias, BillingCountry "
|
| 16 |
+
f"FROM Account WHERE Id='{account_id}' LIMIT 1")
|
| 17 |
+
opps = sf_query(instance_url, access_token,
|
| 18 |
+
f"SELECT Id, Name, StageName, Amount, CloseDate, NextStep, Owner.Alias "
|
| 19 |
+
f"FROM Opportunity WHERE AccountId='{account_id}' ORDER BY LastModifiedDate DESC LIMIT {limit_opps}")
|
| 20 |
+
cases = sf_query(instance_url, access_token,
|
| 21 |
+
f"SELECT Id, CaseNumber, Subject, Status, Priority, Origin, LastModifiedDate "
|
| 22 |
+
f"FROM Case WHERE AccountId='{account_id}' ORDER BY LastModifiedDate DESC LIMIT {limit_cases}")
|
| 23 |
+
tasks = sf_query(instance_url, access_token,
|
| 24 |
+
f"SELECT Id, Subject, ActivityDate, Status, Owner.Alias "
|
| 25 |
+
f"FROM Task WHERE WhatId='{account_id}' OR AccountId='{account_id}' "
|
| 26 |
+
f"ORDER BY ActivityDate DESC LIMIT {limit_tasks}")
|
| 27 |
+
return {
|
| 28 |
+
"account": acct[0] if acct else {},
|
| 29 |
+
"opportunities": opps,
|
| 30 |
+
"cases": cases,
|
| 31 |
+
"activities": tasks
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
def create_note(instance_url: str, access_token: str, account_id: str, title: str, body: str) -> str:
|
| 35 |
+
payload = {"Title": title, "Body": body, "ParentId": account_id}
|
| 36 |
+
res = requests.post(f"{instance_url}/services/data/v61.0/sobjects/Note",
|
| 37 |
+
headers={"Authorization": f"Bearer {access_token}",
|
| 38 |
+
"Content-Type": "application/json"},
|
| 39 |
+
json=payload)
|
| 40 |
+
res.raise_for_status()
|
| 41 |
+
return res.json().get("id")
|
schema.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from pydantic import BaseModel, Field, ValidationError
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
class TimelineItem(BaseModel):
|
| 6 |
+
date: str = Field(..., description="YYYY-MM-DD")
|
| 7 |
+
event: str
|
| 8 |
+
source: str
|
| 9 |
+
|
| 10 |
+
class OpportunityItem(BaseModel):
|
| 11 |
+
name: str
|
| 12 |
+
stage: str
|
| 13 |
+
amount: float
|
| 14 |
+
closeDate: Optional[str] = None
|
| 15 |
+
nextStep: Optional[str] = None
|
| 16 |
+
|
| 17 |
+
class PreCallSummary(BaseModel):
|
| 18 |
+
client_overview: str
|
| 19 |
+
relationship_timeline: List[TimelineItem]
|
| 20 |
+
open_items: List[str]
|
| 21 |
+
opportunities: List[OpportunityItem]
|
| 22 |
+
risks_flags: List[str]
|
| 23 |
+
recommendations: List[str]
|
| 24 |
+
pre_call_questions: List[str]
|
| 25 |
+
sources: List[str]
|
| 26 |
+
|
| 27 |
+
def ensure_schema(d: dict) -> PreCallSummary:
|
| 28 |
+
"""Validate and coerce a dict to PreCallSummary, filling missing keys with safe defaults."""
|
| 29 |
+
defaults = {
|
| 30 |
+
"client_overview": "",
|
| 31 |
+
"relationship_timeline": [],
|
| 32 |
+
"open_items": [],
|
| 33 |
+
"opportunities": [],
|
| 34 |
+
"risks_flags": [],
|
| 35 |
+
"recommendations": [],
|
| 36 |
+
"pre_call_questions": [],
|
| 37 |
+
"sources": []
|
| 38 |
+
}
|
| 39 |
+
merged = {**defaults, **(d or {})}
|
| 40 |
+
try:
|
| 41 |
+
return PreCallSummary.model_validate(merged)
|
| 42 |
+
except ValidationError as e:
|
| 43 |
+
# Best-effort repair for common issues
|
| 44 |
+
for fld in ["relationship_timeline", "open_items", "opportunities", "risks_flags", "recommendations", "pre_call_questions", "sources"]:
|
| 45 |
+
if merged.get(fld) is None:
|
| 46 |
+
merged[fld] = []
|
| 47 |
+
if merged.get("client_overview") is None:
|
| 48 |
+
merged["client_overview"] = ""
|
| 49 |
+
return PreCallSummary.model_validate(merged)
|