项目文件夹

文件
wehub-resource-sync e768098d0e
tools_continuous_delivery / Private PyPI non-main branch release (push) Has been skipped
tools_continuous_delivery / Private PyPI main branch release (push) Failing after 2m42s
Publish Promptflow Doc / Build (push) Has been cancelled
Publish Promptflow Doc / Deploy (push) Has been cancelled
Flake8 Lint / flake8 (push) Has been cancelled
Spell check CI / Spell_Check (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:52 +08:00

287 行
10 KiB
Python

import asyncio
import os
import re
import random
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from functools import partial
import bs4
import requests
from dotenv import load_dotenv
from typing_extensions import Never
from agent_framework import Agent, Executor, WorkflowBuilder, WorkflowContext, handler
from agent_framework.openai import OpenAIChatClient
load_dotenv()
# ---------------------------------------------------------------------------
# Prompt templates (from .jinja2 files)
# ---------------------------------------------------------------------------
EXTRACT_QUERY_INSTRUCTIONS = """\
You are an AI assistant reading the transcript of a conversation between an AI and a human. \
Given an input question and conversation history, infer user real intent.
The conversation history is provided just in case of a context \
(e.g. "What is this?" where "this" is defined in previous conversation).
Return the output as query used for next round user message."""
AUGMENTED_CHAT_INSTRUCTIONS = """\
You are a chatbot having a conversation with a human.
Given the following extracted parts of a long document and a question, \
create a final answer with references ("SOURCES").
If you don't know the answer, just say that you don't know. Don't try to make up an answer.
ALWAYS return a "SOURCES" part in your answer."""
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class ChatInput:
question: str
chat_history: list = field(default_factory=list)
@dataclass
class QueryWithContext:
question: str
chat_history: list
contexts: str
# ---------------------------------------------------------------------------
# Wikipedia helper functions (from get_wiki_url.py, search_result_from_url.py,
# process_search_result.py)
# ---------------------------------------------------------------------------
_session = requests.Session()
def _decode_str(string):
return string.encode().decode("unicode-escape").encode("latin1").decode("utf-8")
def _remove_nested_parentheses(string):
pattern = r"\([^()]+\)"
while re.search(pattern, string):
string = re.sub(pattern, "", string)
return string
def get_wiki_url(entity: str, count: int = 2) -> list[str]:
url = f"https://en.wikipedia.org/w/index.php?search={entity}"
url_list: list[str] = []
try:
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.35"
)
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
soup = bs4.BeautifulSoup(response.text, "html.parser")
mw_divs = soup.find_all("div", {"class": "mw-search-result-heading"})
if mw_divs:
result_titles = [_decode_str(div.get_text().strip()) for div in mw_divs]
result_titles = [_remove_nested_parentheses(t) for t in result_titles]
url_list.extend(
f"https://en.wikipedia.org/w/index.php?search={t}" for t in result_titles
)
else:
page_content = [p.get_text().strip() for p in soup.find_all("p") + soup.find_all("ul")]
if any("may refer to:" in p for p in page_content):
url_list.extend(get_wiki_url("[" + entity + "]"))
else:
url_list.append(url)
return url_list[:count]
except Exception as e:
print(f"Get url failed with error: {e}")
return url_list
def _get_page_sentence(page: str, count: int = 10) -> str:
paragraphs = [p.strip() for p in page.split("\n") if p.strip()]
sentences: list[str] = []
for p in paragraphs:
sentences += p.split(". ")
sentences = [s.strip() + "." for s in sentences if s.strip()]
return " ".join(sentences[:count])
def _fetch_text_content_from_url(url: str, count: int = 10) -> tuple[str, str]:
try:
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.35"
)
}
delay = random.uniform(0, 0.5)
time.sleep(delay)
response = _session.get(url, headers=headers)
if response.status_code == 200:
soup = bs4.BeautifulSoup(response.text, "html.parser")
page_content = [p.get_text().strip() for p in soup.find_all("p") + soup.find_all("ul")]
page = ""
for content in page_content:
if len(content.split(" ")) > 2:
page += _decode_str(content)
if not content.endswith("\n"):
page += "\n"
return (url, _get_page_sentence(page, count=count))
return (url, "No available content")
except Exception as e:
print(f"Get url failed with error: {e}")
return (url, "No available content")
def search_result_from_url(url_list: list[str], count: int = 10) -> list[tuple[str, str]]:
results: list[tuple[str, str]] = []
fn = partial(_fetch_text_content_from_url, count=count)
with ThreadPoolExecutor(max_workers=5) as executor:
for r in executor.map(fn, url_list):
results.append(r)
return results
def process_search_result(search_result: list[tuple[str, str]]) -> str:
context = []
for url, content in search_result:
context.append({"Content": content, "Source": url})
return "\n\n".join(f"Content: {c['Content']}\nSource: {c['Source']}" for c in context)
# ---------------------------------------------------------------------------
# Executors
# ---------------------------------------------------------------------------
class InputExecutor(Executor):
"""Passes the ChatInput through to the next stage."""
@handler
async def receive(self, chat_input: ChatInput, ctx: WorkflowContext[ChatInput]) -> None:
await ctx.send_message(chat_input)
class ExtractQueryExecutor(Executor):
"""LLM node: extracts a search query from the user question + chat history."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
client = OpenAIChatClient(
azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
model=os.environ.get("AZURE_OPENAI_DEPLOYMENT", "gpt-35-turbo"),
api_key=os.environ["AZURE_OPENAI_API_KEY"],
)
self._agent = Agent(
client=client,
name="ExtractQueryAgent",
instructions=EXTRACT_QUERY_INSTRUCTIONS,
)
@handler
async def extract(self, chat_input: ChatInput, ctx: WorkflowContext[ChatInput]) -> None:
# Build the prompt with history
parts: list[str] = []
for turn in chat_input.chat_history:
parts.append(f"Human: {turn['inputs']['question']}")
parts.append(f"AI: {turn['outputs']['answer']}")
parts.append(f"Human: {chat_input.question}")
parts.append("\nOutput:")
response = await self._agent.run("\n".join(parts))
# Replace the question with the extracted query for downstream
await ctx.send_message(
ChatInput(
question=response.text.strip(),
chat_history=chat_input.chat_history,
)
)
class WikiSearchExecutor(Executor):
"""Python node: searches Wikipedia and retrieves page content."""
@handler
async def search(self, chat_input: ChatInput, ctx: WorkflowContext[QueryWithContext]) -> None:
entity = chat_input.question
urls = get_wiki_url(entity, count=2)
search_results = search_result_from_url(urls, count=10)
contexts = process_search_result(search_results)
await ctx.send_message(
QueryWithContext(
question=chat_input.question,
chat_history=chat_input.chat_history,
contexts=contexts,
)
)
class AugmentedChatExecutor(Executor):
"""LLM node: answers the question using Wikipedia context."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
client = OpenAIChatClient(
azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
model=os.environ.get("AZURE_OPENAI_DEPLOYMENT", "gpt-35-turbo"),
api_key=os.environ["AZURE_OPENAI_API_KEY"],
)
self._agent = Agent(
client=client,
name="AugmentedChatAgent",
instructions=AUGMENTED_CHAT_INSTRUCTIONS,
)
@handler
async def answer(self, qc: QueryWithContext, ctx: WorkflowContext[Never, str]) -> None:
parts: list[str] = [qc.contexts, ""]
for turn in qc.chat_history:
parts.append(f"User: {turn['inputs']['question']}")
parts.append(f"Assistant: {turn['outputs']['answer']}")
parts.append(qc.question)
response = await self._agent.run("\n".join(parts))
await ctx.yield_output(response.text)
# ---------------------------------------------------------------------------
# Workflow: input → extract_query → wiki_search → augmented_chat → output
# ---------------------------------------------------------------------------
def create_workflow():
"""Create a fresh workflow instance.
MAF workflows do not support concurrent execution, so each
concurrent caller needs its own workflow instance.
"""
_input = InputExecutor(id="input")
_extract = ExtractQueryExecutor(id="extract_query")
_wiki = WikiSearchExecutor(id="wiki_search")
_chat = AugmentedChatExecutor(id="augmented_chat")
return (
WorkflowBuilder(name="ChatWithWikipediaWorkflow", start_executor=_input)
.add_edge(_input, _extract)
.add_edge(_extract, _wiki)
.add_edge(_wiki, _chat)
.build()
)
async def main():
workflow = create_workflow()
result = await workflow.run(ChatInput(question="What is ChatGPT?"))
print("Answer:", result.get_outputs()[0])
if __name__ == "__main__":
asyncio.run(main())