microsoft--promptflow
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
287 行
10 KiB
Python
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())
|