Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files- llm_services/__init__.py +1 -0
- llm_services/agent_prompts.py +58 -0
- llm_services/llm_client.py +25 -0
- llm_services/llm_usage.py +30 -0
- llm_services/providers/__init__.py +1 -0
- llm_services/providers/azure.py +37 -0
llm_services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Optional external LLM services for WaterScopeAI-Search."""
|
llm_services/agent_prompts.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exact WaterScopeAI agent prompt text copied from the public model Space app.py.
|
| 2 |
+
Source: EnvironmentalAI/WaterScopeAI, commit b248182, app.py agent_run().
|
| 3 |
+
Do not edit text without incrementing the prompt version.
|
| 4 |
+
"""
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
PROMPT_VERSION = "waterscope-agent-b248182"
|
| 9 |
+
AGENT_APPLICATIONS = {
|
| 10 |
+
"general_qa": {"label": "General environmental QA", "instruction": "Answer the technical question directly. Distinguish facts, assumptions, and recommendations."},
|
| 11 |
+
"document_qa": {"label": "Document-grounded QA", "instruction": "Use the supplied context as the only source for document-specific claims. State clearly when the context does not support an answer."},
|
| 12 |
+
"paper_synthesis": {"label": "Multi-paper synthesis", "instruction": "Compare the supplied papers with explicit source attribution. Preserve key methods and quantitative findings and do not mix evidence among papers."},
|
| 13 |
+
"method_recommendation": {"label": "Method recommendation", "instruction": "Recommend a method using explicit evidence, implementation constraints, limitations, and uncertainty. Do not claim superiority unless the supplied evidence directly supports it."},
|
| 14 |
+
"research_gaps": {"label": "Research-gap identification", "instruction": "Identify evidence-grounded research gaps. Link every gap to a specific limitation or unresolved issue in the supplied context."},
|
| 15 |
+
"claim_check": {"label": "Unsupported-claim check", "instruction": "Evaluate whether the requested claim is supported. Refuse to invent a paper, method, value, causal relationship, or percentage."},
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
def build_agent_messages(application: str, context: str, question: str) -> list[dict[str, str]]:
|
| 19 |
+
"""Mirrors WaterScopeAI app.py agent_run() system/user message construction."""
|
| 20 |
+
if application not in AGENT_APPLICATIONS:
|
| 21 |
+
raise ValueError(f"Unknown application: {application}")
|
| 22 |
+
if not isinstance(question, str) or not question.strip():
|
| 23 |
+
raise ValueError("question must be nonempty")
|
| 24 |
+
application_config = AGENT_APPLICATIONS[application]
|
| 25 |
+
system_prompt = f"""
|
| 26 |
+
You are WaterScope-AI, an expert scientific agent for water, wastewater,
|
| 27 |
+
environmental engineering, and sustainability.
|
| 28 |
+
|
| 29 |
+
Application: {application_config["label"]}
|
| 30 |
+
Task behavior: {application_config["instruction"]}
|
| 31 |
+
|
| 32 |
+
Core requirements:
|
| 33 |
+
- Answer the user's actual task directly.
|
| 34 |
+
- Preserve important quantitative values and units.
|
| 35 |
+
- Preserve proper nouns, geographic names, paper titles, symbols, and
|
| 36 |
+
non-ASCII scientific notation exactly as they appear in the supplied context.
|
| 37 |
+
- Do not translate or anglicize geographic names unless the user asks.
|
| 38 |
+
- Attribute document-specific evidence clearly.
|
| 39 |
+
- Separate evidence from inference and recommendation.
|
| 40 |
+
- Never invent papers, citations, numerical values, standards, or findings.
|
| 41 |
+
- When evidence is insufficient, say exactly what cannot be concluded.
|
| 42 |
+
- Avoid repeated sentences or sections and stop when complete.
|
| 43 |
+
""".strip()
|
| 44 |
+
context = (context or "").strip()
|
| 45 |
+
user_content = question.strip()
|
| 46 |
+
if context:
|
| 47 |
+
user_content = (
|
| 48 |
+
"SUPPLIED CONTEXT\n"
|
| 49 |
+
"================\n"
|
| 50 |
+
f"{context}\n\n"
|
| 51 |
+
"USER TASK\n"
|
| 52 |
+
"=========\n"
|
| 53 |
+
f"{question.strip()}"
|
| 54 |
+
)
|
| 55 |
+
return [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}]
|
| 56 |
+
|
| 57 |
+
def prompt_sha256(messages: list[dict]) -> str:
|
| 58 |
+
return hashlib.sha256(json.dumps(messages, ensure_ascii=False, sort_keys=True).encode('utf-8')).hexdigest()
|
llm_services/llm_client.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified optional external-model call; no changes to HF inference routing."""
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
from .agent_prompts import build_agent_messages, prompt_sha256, PROMPT_VERSION
|
| 5 |
+
from .llm_usage import usage_summary
|
| 6 |
+
|
| 7 |
+
def generate(messages, *, provider='azure', model=None, max_completion_tokens=1200):
|
| 8 |
+
if os.getenv("ENABLE_EXTERNAL_LLM", "false").lower() not in {"1", "true", "yes"}:
|
| 9 |
+
raise RuntimeError("External LLM calls are disabled (ENABLE_EXTERNAL_LLM=false)")
|
| 10 |
+
if provider != "azure":
|
| 11 |
+
raise ValueError(f"Unsupported provider: {provider}. Azure is implemented in Stage 1.")
|
| 12 |
+
from .providers.azure import complete
|
| 13 |
+
started = time.monotonic()
|
| 14 |
+
result = complete(messages, model=model, max_completion_tokens=max_completion_tokens)
|
| 15 |
+
result['elapsed_s'] = round(time.monotonic() - started, 3)
|
| 16 |
+
result['metering'] = usage_summary(result)
|
| 17 |
+
return result
|
| 18 |
+
|
| 19 |
+
def run_application(*, application, context, question, provider='azure', model=None, max_completion_tokens=1200):
|
| 20 |
+
messages = build_agent_messages(application, context, question)
|
| 21 |
+
result = generate(messages, provider=provider, model=model, max_completion_tokens=max_completion_tokens)
|
| 22 |
+
result['application'] = application
|
| 23 |
+
result['prompt_version'] = PROMPT_VERSION
|
| 24 |
+
result['prompt_sha256'] = prompt_sha256(messages)
|
| 25 |
+
return result
|
llm_services/llm_usage.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Safe usage summaries; no prompts or credential values are stored.
|
| 2 |
+
Provide provider-specific prices through environment variables if desired;
|
| 3 |
+
otherwise estimated_cost_usd is None (not an asserted price).
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
def estimate_cost_usd(provider: str, usage: dict):
|
| 8 |
+
prefix = provider.upper()
|
| 9 |
+
in_price = os.getenv(f"{prefix}_INPUT_USD_PER_M")
|
| 10 |
+
out_price = os.getenv(f"{prefix}_OUTPUT_USD_PER_M")
|
| 11 |
+
if not in_price or not out_price:
|
| 12 |
+
return None
|
| 13 |
+
n_in = usage.get("input_tokens")
|
| 14 |
+
n_out = usage.get("output_tokens")
|
| 15 |
+
if n_in is None or n_out is None:
|
| 16 |
+
return None
|
| 17 |
+
try:
|
| 18 |
+
cost = (n_in * float(in_price) + n_out * float(out_price)) / 1_000_000
|
| 19 |
+
return round(cost, 8)
|
| 20 |
+
except (ValueError, TypeError):
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
def usage_summary(result: dict):
|
| 24 |
+
return {
|
| 25 |
+
"provider": result["provider"],
|
| 26 |
+
"model": result["model_requested"],
|
| 27 |
+
"elapsed_s": result.get("elapsed_s"),
|
| 28 |
+
"usage": result.get("usage", {}),
|
| 29 |
+
"estimated_cost_usd": estimate_cost_usd(result["provider"], result.get("usage", {})),
|
| 30 |
+
}
|
llm_services/providers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Provider adapters."""
|
llm_services/providers/azure.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Azure Chat Completions adapter. Secrets never appear in responses/logs."""
|
| 2 |
+
import os
|
| 3 |
+
from openai import AzureOpenAI
|
| 4 |
+
|
| 5 |
+
def create_client():
|
| 6 |
+
endpoint = os.environ["AZURE_OPENAI_ENDPOINT"]
|
| 7 |
+
key = os.environ["AZURE_OPENAI_API_KEY"]
|
| 8 |
+
return AzureOpenAI(
|
| 9 |
+
azure_endpoint=endpoint,
|
| 10 |
+
api_key=key,
|
| 11 |
+
api_version=os.getenv("AZURE_OPENAI_API_VERSION", "2024-12-01-preview"),
|
| 12 |
+
timeout=float(os.getenv("EXTERNAL_LLM_TIMEOUT_S", "120")),
|
| 13 |
+
max_retries=1,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
def complete(messages, *, model=None, max_completion_tokens=1200):
|
| 17 |
+
deployment = model or os.getenv("AZURE_LLM_DEPLOYMENT", "gpt-5.4-mini")
|
| 18 |
+
response = create_client().chat.completions.create(
|
| 19 |
+
model=deployment,
|
| 20 |
+
messages=messages,
|
| 21 |
+
max_completion_tokens=max_completion_tokens,
|
| 22 |
+
)
|
| 23 |
+
choice = response.choices[0]
|
| 24 |
+
usage = response.usage
|
| 25 |
+
details = getattr(usage, "prompt_tokens_details", None) if usage else None
|
| 26 |
+
return {
|
| 27 |
+
"answer": choice.message.content or "",
|
| 28 |
+
"provider": "azure", "model_requested": deployment,
|
| 29 |
+
"model_returned": response.model,
|
| 30 |
+
"finish_reason": choice.finish_reason,
|
| 31 |
+
"usage": {
|
| 32 |
+
"input_tokens": usage.prompt_tokens if usage else None,
|
| 33 |
+
"output_tokens": usage.completion_tokens if usage else None,
|
| 34 |
+
"total_tokens": usage.total_tokens if usage else None,
|
| 35 |
+
"cached_input_tokens": getattr(details, "cached_tokens", None),
|
| 36 |
+
},
|
| 37 |
+
}
|