chore: import upstream snapshot with attribution
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
@@ -0,0 +1,9 @@
|
||||
# Copy this file to .env and fill in your values.
|
||||
# cp .env.example .env
|
||||
|
||||
# Azure OpenAI endpoint
|
||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
|
||||
|
||||
AZURE_OPENAI_API_KEY=your-api-key
|
||||
|
||||
AZURE_OPENAI_DEPLOYMENT=gpt-4o
|
||||
@@ -0,0 +1,46 @@
|
||||
# Basic Chat — Microsoft Agent Framework
|
||||
|
||||
This is the [Microsoft Agent Framework (MAF)](https://devblogs.microsoft.com/agent-framework/microsoft-agent-framework-version-1-0/) version of the [chat-basic](../chat-basic/) Prompt Flow example.
|
||||
|
||||
It implements the same behaviour: a helpful assistant chatbot that remembers conversation history and responds to user questions.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
[InputExecutor] ──→ [ChatExecutor]
|
||||
(question + (Agent with
|
||||
chat_history) FoundryChatClient)
|
||||
```
|
||||
|
||||
| Prompt Flow concept | MAF equivalent |
|
||||
|---|---|
|
||||
| `flow.dag.yaml` | `WorkflowBuilder` in `chat_flow.py` |
|
||||
| `chat.jinja2` (system prompt) | `Agent(instructions="You are a helpful assistant.")` |
|
||||
| LLM node (`api: chat`) | `FoundryChatClient` + `Agent.run()` |
|
||||
| `chat_history` input | Message list assembled in `InputExecutor` |
|
||||
| `open_ai_connection` | Environment variables (`FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`) + `DefaultAzureCredential` |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- An Azure subscription with a Microsoft Foundry project (or Azure OpenAI resource)
|
||||
- `az login` completed
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
|
||||
cp .env.example .env
|
||||
# Edit .env with your Foundry project endpoint and model deployment name
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python chat_flow.py
|
||||
```
|
||||
|
||||
This runs two test interactions:
|
||||
1. A single-turn question with no history
|
||||
2. A follow-up question with one prior turn of chat history
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Basic Chat — Microsoft Agent Framework version.
|
||||
|
||||
Migrated from the Prompt Flow chat-basic example.
|
||||
Original flow: Input (question + chat_history) → LLM node → answer
|
||||
|
||||
This workflow uses a single Agent with FoundryChatClient to replicate
|
||||
the same chat behaviour: a helpful assistant that remembers conversation
|
||||
history and responds to user questions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatInput:
|
||||
"""Mirrors the Prompt Flow inputs: question + chat_history."""
|
||||
|
||||
question: str
|
||||
chat_history: list | None = None
|
||||
|
||||
|
||||
class InputExecutor(Executor):
|
||||
"""Replaces the Prompt Flow Input node.
|
||||
|
||||
Accepts a ChatInput, formats the conversation history into the prompt,
|
||||
and forwards the assembled prompt string to the LLM executor.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def receive(self, chat_input: ChatInput, ctx: WorkflowContext[str]) -> None:
|
||||
parts = []
|
||||
|
||||
# Replay chat history as a formatted conversation
|
||||
if chat_input.chat_history:
|
||||
for turn in chat_input.chat_history:
|
||||
parts.append(f"User: {turn['inputs']['question']}")
|
||||
parts.append(f"Assistant: {turn['outputs']['answer']}")
|
||||
|
||||
# Append the current user question
|
||||
parts.append(chat_input.question)
|
||||
|
||||
await ctx.send_message("\n".join(parts))
|
||||
|
||||
|
||||
class ChatExecutor(Executor):
|
||||
"""Replaces the Prompt Flow LLM (chat) node.
|
||||
|
||||
Uses OpenAIChatClient (Azure routing) + Agent with the same system prompt
|
||||
as the original chat.jinja2 template: "You are a helpful assistant."
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
client = OpenAIChatClient(
|
||||
azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
|
||||
model=os.environ["AZURE_OPENAI_DEPLOYMENT"],
|
||||
api_key=os.environ["AZURE_OPENAI_API_KEY"],
|
||||
)
|
||||
self._agent = Agent(
|
||||
client=client,
|
||||
name="ChatAgent",
|
||||
instructions="You are a helpful assistant.",
|
||||
)
|
||||
|
||||
@handler
|
||||
async def call_llm(self, question: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
response = await self._agent.run(question)
|
||||
await ctx.yield_output(response.text)
|
||||
|
||||
|
||||
# ── Build the workflow ────────────────────────────────────────────────────────
|
||||
def create_workflow():
|
||||
"""Return a fresh workflow instance (safe for concurrent / repeated runs)."""
|
||||
_input = InputExecutor(id="input")
|
||||
_chat = ChatExecutor(id="chat")
|
||||
return (
|
||||
WorkflowBuilder(name="BasicChatWorkflow", start_executor=_input)
|
||||
.add_edge(_input, _chat)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
# Simple single-turn test (no history)
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(ChatInput(question="What is ChatGPT?"))
|
||||
print("Answer:", result.get_outputs()[0])
|
||||
print()
|
||||
|
||||
# Multi-turn test (with chat history)
|
||||
history = [
|
||||
{
|
||||
"inputs": {"question": "What is ChatGPT?"},
|
||||
"outputs": {"answer": "ChatGPT is a large language model chatbot developed by OpenAI."},
|
||||
}
|
||||
]
|
||||
workflow2 = create_workflow()
|
||||
result = await workflow2.run(
|
||||
ChatInput(
|
||||
question="What is the difference between ChatGPT and GPT-4?",
|
||||
chat_history=history,
|
||||
)
|
||||
)
|
||||
print("Answer:", result.get_outputs()[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
agent-framework>=1.0.1
|
||||
agent-framework-openai>=1.0.1
|
||||
python-dotenv
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Sample script to test the Basic Chat MAF workflow.
|
||||
|
||||
Run:
|
||||
python test_chat_flow.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from chat_flow import ChatInput, create_workflow
|
||||
|
||||
|
||||
async def main():
|
||||
# Test 1: Single-turn — no chat history
|
||||
print("=" * 60)
|
||||
print("Test 1: Single-turn (no history)")
|
||||
print("=" * 60)
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(ChatInput(question="What is ChatGPT?"))
|
||||
print("Q: What is ChatGPT?")
|
||||
print(f"A: {result.get_outputs()[0]}\n")
|
||||
|
||||
# Test 2: Multi-turn — with one prior exchange
|
||||
print("=" * 60)
|
||||
print("Test 2: Multi-turn (with history)")
|
||||
print("=" * 60)
|
||||
history = [
|
||||
{
|
||||
"inputs": {"question": "What is ChatGPT?"},
|
||||
"outputs": {"answer": "ChatGPT is a large language model chatbot developed by OpenAI."},
|
||||
}
|
||||
]
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
ChatInput(
|
||||
question="How is it different from GPT-4?",
|
||||
chat_history=history,
|
||||
)
|
||||
)
|
||||
print("Q: How is it different from GPT-4?")
|
||||
print(f"A: {result.get_outputs()[0]}\n")
|
||||
|
||||
# Test 3: Multi-turn — longer conversation
|
||||
print("=" * 60)
|
||||
print("Test 3: Multi-turn (longer conversation)")
|
||||
print("=" * 60)
|
||||
history = [
|
||||
{
|
||||
"inputs": {"question": "What is 2+2?"},
|
||||
"outputs": {"answer": "4"},
|
||||
},
|
||||
{
|
||||
"inputs": {"question": "Multiply that by 3"},
|
||||
"outputs": {"answer": "12"},
|
||||
},
|
||||
]
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
ChatInput(
|
||||
question="Now divide by 6",
|
||||
chat_history=history,
|
||||
)
|
||||
)
|
||||
print("Q: Now divide by 6")
|
||||
print(f"A: {result.get_outputs()[0]}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,70 @@
|
||||
# Basic Chat
|
||||
This example shows how to create a basic chat flow. It demonstrates how to create a chatbot that can remember previous interactions and use the conversation history to generate next message.
|
||||
|
||||
Tools used in this flow:
|
||||
- `llm` tool
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install promptflow sdk and other dependencies in this folder:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## What you will learn
|
||||
|
||||
In this flow, you will learn
|
||||
- how to compose a chat flow.
|
||||
- prompt template format of LLM tool chat api. Message delimiter is a separate line containing "#", role name and colon: "# system:", "# user:", "# assistant:".
|
||||
See <a href="https://platform.openai.com/docs/api-reference/chat/create#chat/create-role" target="_blank">OpenAI Chat</a> for more about message role.
|
||||
```jinja
|
||||
# system:
|
||||
You are a chatbot having a conversation with a human.
|
||||
|
||||
# user:
|
||||
{{question}}
|
||||
```
|
||||
- how to consume chat history in prompt.
|
||||
```jinja
|
||||
{% for item in chat_history %}
|
||||
# user:
|
||||
{{item.inputs.question}}
|
||||
# assistant:
|
||||
{{item.outputs.answer}}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
## Getting started
|
||||
|
||||
### 1 Create connection for LLM tool to use
|
||||
Go to "Prompt flow" "Connections" tab. Click on "Create" button, select one of LLM tool supported connection types and fill in the configurations.
|
||||
|
||||
Currently, there are two connection types supported by LLM tool: "AzureOpenAI" and "OpenAI". If you want to use "AzureOpenAI" connection type, you need to create an Azure OpenAI service first. Please refer to [Azure OpenAI Service](https://azure.microsoft.com/en-us/products/cognitive-services/openai-service/) for more details. If you want to use "OpenAI" connection type, you need to create an OpenAI account first. Please refer to [OpenAI](https://platform.openai.com/) for more details.
|
||||
|
||||
```bash
|
||||
# Override keys with --set to avoid yaml file changes
|
||||
pf connection create --file ../../../connections/azure_openai.yml --set api_key=<your_api_key> api_base=<your_api_base> --name open_ai_connection
|
||||
```
|
||||
|
||||
Note in [flow.dag.yaml](flow.dag.yaml) we are using connection named `open_ai_connection`.
|
||||
```bash
|
||||
# show registered connection
|
||||
pf connection show --name open_ai_connection
|
||||
```
|
||||
|
||||
### 2 Start chatting
|
||||
|
||||
```bash
|
||||
# run chat flow with default question in flow.dag.yaml
|
||||
pf flow test --flow .
|
||||
|
||||
# run chat flow with new question
|
||||
pf flow test --flow . --inputs question="What's Azure Machine Learning?"
|
||||
|
||||
# start a interactive chat session in CLI
|
||||
pf flow test --flow . --interactive
|
||||
|
||||
# start a interactive chat session in CLI with verbose info
|
||||
pf flow test --flow . --interactive --verbose
|
||||
```
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# system:
|
||||
You are a helpful assistant.
|
||||
|
||||
{% for item in chat_history %}
|
||||
# user:
|
||||
{{item.inputs.question}}
|
||||
# assistant:
|
||||
{{item.outputs.answer}}
|
||||
{% endfor %}
|
||||
|
||||
# user:
|
||||
{{question}}
|
||||
@@ -0,0 +1,34 @@
|
||||
$schema: https://azuremlschemas.azureedge.net/promptflow/latest/Flow.schema.json
|
||||
inputs:
|
||||
chat_history:
|
||||
type: list
|
||||
default: []
|
||||
question:
|
||||
type: string
|
||||
is_chat_input: true
|
||||
default: What is ChatGPT?
|
||||
outputs:
|
||||
answer:
|
||||
type: string
|
||||
reference: ${chat.output}
|
||||
is_chat_output: true
|
||||
nodes:
|
||||
- inputs:
|
||||
# This is to easily switch between openai and azure openai.
|
||||
# deployment_name is required by azure openai, model is required by openai.
|
||||
deployment_name: gpt-35-turbo
|
||||
model: gpt-3.5-turbo
|
||||
max_tokens: "256"
|
||||
temperature: "0.7"
|
||||
chat_history: ${inputs.chat_history}
|
||||
question: ${inputs.question}
|
||||
name: chat
|
||||
type: llm
|
||||
source:
|
||||
type: code
|
||||
path: chat.jinja2
|
||||
api: chat
|
||||
connection: open_ai_connection
|
||||
node_variants: {}
|
||||
environment:
|
||||
python_requirements_txt: requirements.txt
|
||||
@@ -0,0 +1,2 @@
|
||||
promptflow
|
||||
promptflow-tools
|
||||
@@ -0,0 +1,7 @@
|
||||
# Azure OpenAI credentials
|
||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
|
||||
AZURE_OPENAI_API_KEY=your-api-key
|
||||
AZURE_OPENAI_DEPLOYMENT=gpt-4
|
||||
|
||||
# Which variant to use: variant_0, variant_1, or variant_2
|
||||
CHAT_VARIANT=variant_0
|
||||
@@ -0,0 +1,3 @@
|
||||
agent-framework>=1.0.1
|
||||
agent-framework-openai>=1.0.1
|
||||
python-dotenv
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Test script for the chat-math-variant MAF workflow."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from workflow import ChatInput, build_workflow # noqa: E402
|
||||
|
||||
|
||||
async def test_single_turn():
|
||||
"""Single-turn: ask a simple math question with each variant."""
|
||||
for variant in ("variant_0", "variant_1", "variant_2"):
|
||||
print(f"\n--- {variant} ---")
|
||||
workflow = build_workflow(variant)
|
||||
result = await workflow.run(ChatInput(question="1+1=?", variant=variant))
|
||||
print("Answer:", result.get_outputs()[0])
|
||||
|
||||
|
||||
async def test_multi_turn():
|
||||
"""Multi-turn: simulate a conversation, then ask for the sum of all previous answers."""
|
||||
workflow = build_workflow("variant_0")
|
||||
|
||||
chat_history = [
|
||||
{"inputs": {"question": "1+1=?"}, "outputs": {"answer": "2"}},
|
||||
{"inputs": {"question": "2+2=?"}, "outputs": {"answer": "4"}},
|
||||
{"inputs": {"question": "3+3=?"}, "outputs": {"answer": "6"}},
|
||||
]
|
||||
|
||||
result = await workflow.run(
|
||||
ChatInput(
|
||||
question="What is the sum of all the answers you gave so far?",
|
||||
chat_history=chat_history,
|
||||
variant="variant_0",
|
||||
)
|
||||
)
|
||||
print("\n--- multi-turn (variant_0) ---")
|
||||
print("Answer:", result.get_outputs()[0])
|
||||
|
||||
|
||||
async def main():
|
||||
await test_single_turn()
|
||||
await test_multi_turn()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,201 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
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()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variant system prompts (converted from .jinja2 templates)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VARIANT_0_INSTRUCTIONS = (
|
||||
"You are an assistant to calculate the answer to the provided math problems.\n"
|
||||
"Please return the final numerical answer only, without any accompanying reasoning or explanation."
|
||||
)
|
||||
|
||||
VARIANT_1_INSTRUCTIONS = """\
|
||||
You are an assistant to calculate the answer to the provided math problems.
|
||||
Please think step by step.
|
||||
Return the final numerical answer only and any accompanying reasoning or explanation seperately as json format.
|
||||
|
||||
Here are some examples:
|
||||
|
||||
User: A jar contains two red marbles, three green marbles, ten white marbles \
|
||||
and no other marbles. Two marbles are randomly drawn from this jar without \
|
||||
replacement. What is the probability that these two marbles drawn will both \
|
||||
be red? Express your answer as a common fraction.
|
||||
Assistant: {"Chain of thought": "The total number of marbles is 2+3+10=15. \
|
||||
The probability that the first marble drawn will be red is 2/15. Then, there \
|
||||
will be one red left, out of 14. Therefore, the probability of drawing out \
|
||||
two red marbles will be: (2/15)*(1/14) = 1/105.", "answer": "1/105"}
|
||||
|
||||
User: Find the greatest common divisor of 7! and (5!)^2.
|
||||
Assistant: {"Chain of thought": "7! = 5040 = 2^4 * 3^2 * 5 * 7. \
|
||||
(5!)^2 = 14400 = 2^6 * 3^2 * 5^2. gcd = 2^4 * 3^2 * 5 = 720.", \
|
||||
"answer": "720"}"""
|
||||
|
||||
VARIANT_2_INSTRUCTIONS = """\
|
||||
You are an assistant to calculate the answer to the provided math problems.
|
||||
Please think step by step.
|
||||
Return the final numerical answer only and any accompanying reasoning or explanation seperately as json format.
|
||||
|
||||
Here are some examples:
|
||||
|
||||
User: A jar contains two red marbles, three green marbles, ten white marbles \
|
||||
and no other marbles. Two marbles are randomly drawn from this jar without \
|
||||
replacement. What is the probability that these two marbles drawn will both \
|
||||
be red? Express your answer as a common fraction.
|
||||
Assistant: {"Chain of thought": "The total number of marbles is 2+3+10=15. \
|
||||
The probability that the first marble drawn will be red is 2/15. Then, there \
|
||||
will be one red left, out of 14. Therefore, the probability of drawing out \
|
||||
two red marbles will be: (2/15)*(1/14) = 1/105.", "answer": "1/105"}
|
||||
|
||||
User: Find the greatest common divisor of 7! and (5!)^2.
|
||||
Assistant: {"Chain of thought": "7! = 5040 = 2^4 * 3^2 * 5 * 7. \
|
||||
(5!)^2 = 14400 = 2^6 * 3^2 * 5^2. gcd = 2^4 * 3^2 * 5 = 720.", \
|
||||
"answer": "720"}
|
||||
|
||||
User: A club has 10 members, 5 boys and 5 girls. Two of the members \
|
||||
are chosen at random. What is the probability that they are both girls?
|
||||
Assistant: {"Chain of thought": "There are C(10,2) = 45 ways to choose \
|
||||
two members, and C(5,2) = 10 ways to choose two girls. Therefore, \
|
||||
the probability is 10/45 = 2/9.", "answer": "2/9"}
|
||||
|
||||
User: Allison, Brian and Noah each have a 6-sided cube. All of the faces \
|
||||
on Allison's cube have a 5. The faces on Brian's cube are numbered \
|
||||
1, 2, 3, 4, 5 and 6. Three of the faces on Noah's cube have a 2 and \
|
||||
three of the faces have a 6. All three cubes are rolled. What is the \
|
||||
probability that Allison's roll is greater than each of Brian's and Noah's?
|
||||
Assistant: {"Chain of thought": "Allison always rolls 5. Brian rolls 4 \
|
||||
or lower with probability 4/6 = 2/3. Noah rolls 2 (less than 5) with \
|
||||
probability 3/6 = 1/2. So the probability is 2/3 * 1/2 = 1/3.", \
|
||||
"answer": "1/3"}
|
||||
|
||||
User: Compute C(50,2).
|
||||
Assistant: {"Chain of thought": "C(50,2) = 50!/(2!48!) = (50*49)/(2*1) \
|
||||
= 1225.", "answer": "1225"}
|
||||
|
||||
User: The set S = {1, 2, 3, ..., 49, 50} contains the first 50 positive \
|
||||
integers. After the multiples of 2 and the multiples of 3 are removed, \
|
||||
how many integers remain in the set S?
|
||||
Assistant: {"Chain of thought": "25 even numbers removed, leaving 25 odd \
|
||||
numbers. Remove odd multiples of 3: 3,9,15,21,27,33,39,45 = 8 numbers. \
|
||||
25 - 8 = 17.", "answer": "17"}"""
|
||||
|
||||
VARIANT_INSTRUCTIONS = {
|
||||
"variant_0": VARIANT_0_INSTRUCTIONS,
|
||||
"variant_1": VARIANT_1_INSTRUCTIONS,
|
||||
"variant_2": VARIANT_2_INSTRUCTIONS,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatInput:
|
||||
question: str
|
||||
chat_history: list = field(default_factory=list)
|
||||
variant: str = "variant_0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Executors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InputExecutor(Executor):
|
||||
"""Formats the chat history and question into a single prompt string."""
|
||||
|
||||
@handler
|
||||
async def receive(self, chat_input: ChatInput, ctx: WorkflowContext[str]) -> None:
|
||||
parts: list[str] = []
|
||||
if chat_input.chat_history:
|
||||
for turn in chat_input.chat_history:
|
||||
parts.append(f"User: {turn['inputs']['question']}")
|
||||
parts.append(f"Assistant: {turn['outputs']['answer']}")
|
||||
parts.append(chat_input.question)
|
||||
await ctx.send_message("\n".join(parts))
|
||||
|
||||
|
||||
class ChatExecutor(Executor):
|
||||
"""Calls the LLM with the selected variant instructions."""
|
||||
|
||||
def __init__(self, variant: str = "variant_0", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
client = OpenAIChatClient(
|
||||
azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
|
||||
model=os.environ.get("AZURE_OPENAI_DEPLOYMENT", "gpt-4"),
|
||||
api_key=os.environ["AZURE_OPENAI_API_KEY"],
|
||||
)
|
||||
self._agent = Agent(
|
||||
client=client,
|
||||
name="ChatMathAgent",
|
||||
instructions=VARIANT_INSTRUCTIONS[variant],
|
||||
)
|
||||
|
||||
@handler
|
||||
async def call_llm(self, prompt: str, ctx: WorkflowContext[str]) -> None:
|
||||
response = await self._agent.run(prompt)
|
||||
await ctx.send_message(response.text)
|
||||
|
||||
|
||||
class ExtractResultExecutor(Executor):
|
||||
"""Extracts the final answer from the LLM response (mirrors extract_result.py)."""
|
||||
|
||||
@handler
|
||||
async def extract(self, llm_output: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
cleaned = re.sub(r'[$\\!]', '', llm_output)
|
||||
try:
|
||||
json_answer = json.loads(cleaned)
|
||||
answer = json_answer["answer"]
|
||||
except Exception:
|
||||
answer = cleaned
|
||||
await ctx.yield_output(answer)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workflow builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_workflow(variant: str = "variant_0"):
|
||||
"""Build the chat-math workflow for the given variant."""
|
||||
_input = InputExecutor(id="input")
|
||||
_chat = ChatExecutor(id="chat", variant=variant)
|
||||
_extract = ExtractResultExecutor(id="extract_result")
|
||||
|
||||
return (
|
||||
WorkflowBuilder(name="ChatMathVariantWorkflow", start_executor=_input)
|
||||
.add_edge(_input, _chat)
|
||||
.add_edge(_chat, _extract)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def main():
|
||||
variant = os.environ.get("CHAT_VARIANT", "variant_0")
|
||||
workflow = build_workflow(variant)
|
||||
|
||||
result = await workflow.run(
|
||||
ChatInput(question="1+1=?", variant=variant)
|
||||
)
|
||||
print("Answer:", result.get_outputs()[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
__pycache__/
|
||||
.promptflow/*
|
||||
!.promptflow/flow.tools.json
|
||||
.runs/
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"package": {},
|
||||
"code": {
|
||||
"chat.jinja2": {
|
||||
"type": "llm",
|
||||
"inputs": {
|
||||
"chat_history": {
|
||||
"type": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"question": {
|
||||
"type": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"source": "chat.jinja2"
|
||||
},
|
||||
"chat_variant_1.jinja2": {
|
||||
"type": "llm",
|
||||
"inputs": {
|
||||
"chat_history": {
|
||||
"type": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"question": {
|
||||
"type": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"source": "chat_variant_1.jinja2"
|
||||
},
|
||||
"chat_variant_2.jinja2": {
|
||||
"type": "llm",
|
||||
"inputs": {
|
||||
"chat_history": {
|
||||
"type": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"question": {
|
||||
"type": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"source": "chat_variant_2.jinja2"
|
||||
},
|
||||
"extract_result.py": {
|
||||
"type": "python",
|
||||
"inputs": {
|
||||
"input1": {
|
||||
"type": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"source": "extract_result.py",
|
||||
"function": "my_python_tool"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Test your prompt variants for chat with math
|
||||
This is a prompt tuning case with 3 prompt variants for math question answering.
|
||||
|
||||
By utilizing this flow, in conjunction with the `evaluation/eval-chat-math` flow, you can quickly grasp the advantages of prompt tuning and experimentation with prompt flow. Here we provide a [video](https://www.youtube.com/watch?v=gcIe6nk2gA4) and a [tutorial]((../../../tutorials/flow-fine-tuning-evaluation/promptflow-quality-improvement.md)) for you to get started.
|
||||
|
||||
Tools used in this flow:
|
||||
- `llm` tool
|
||||
- custom `python` Tool
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install promptflow sdk and other dependencies in this folder:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Getting started
|
||||
|
||||
### 1 Create connection for LLM tool to use
|
||||
Go to "Prompt flow" "Connections" tab. Click on "Create" button, select one of LLM tool supported connection types and fill in the configurations.
|
||||
|
||||
Currently, there are two connection types supported by LLM tool: "AzureOpenAI" and "OpenAI". If you want to use "AzureOpenAI" connection type, you need to create an Azure OpenAI service first. Please refer to [Azure OpenAI Service](https://azure.microsoft.com/en-us/products/cognitive-services/openai-service/) for more details. If you want to use "OpenAI" connection type, you need to create an OpenAI account first. Please refer to [OpenAI](https://platform.openai.com/) for more details.
|
||||
|
||||
```bash
|
||||
# Override keys with --set to avoid yaml file changes
|
||||
pf connection create --file ../../../connections/azure_openai.yml --set api_key=<your_api_key> api_base=<your_api_base> --name open_ai_connection
|
||||
```
|
||||
|
||||
Note in [flow.dag.yaml](flow.dag.yaml) we are using connection named `open_ai_connection`.
|
||||
```bash
|
||||
# show registered connection
|
||||
pf connection show --name open_ai_connection
|
||||
```
|
||||
|
||||
### 2 Start chatting
|
||||
|
||||
```bash
|
||||
# run chat flow with default question in flow.dag.yaml
|
||||
pf flow test --flow .
|
||||
|
||||
# run chat flow with new question
|
||||
pf flow test --flow . --inputs question="2+5=?"
|
||||
|
||||
# start a interactive chat session in CLI
|
||||
pf flow test --flow . --interactive
|
||||
|
||||
# start a interactive chat session in CLI with verbose info
|
||||
pf flow test --flow . --interactive --verbose
|
||||
@@ -0,0 +1,13 @@
|
||||
# system:
|
||||
You are an assistant to calculate the answer to the provided math problems.
|
||||
Please return the final numerical answer only, without any accompanying reasoning or explanation.
|
||||
|
||||
{% for item in chat_history %}
|
||||
# user:
|
||||
{{item.inputs.question}}
|
||||
# assistant:
|
||||
{{item.outputs.answer}}
|
||||
{% endfor %}
|
||||
|
||||
# user:
|
||||
{{question}}
|
||||
@@ -0,0 +1,23 @@
|
||||
# system:
|
||||
You are an assistant to calculate the answer to the provided math problems.
|
||||
Please think step by step.
|
||||
Return the final numerical answer only and any accompanying reasoning or explanation seperately as json format.
|
||||
|
||||
# user:
|
||||
A jar contains two red marbles, three green marbles, ten white marbles and no other marbles. Two marbles are randomly drawn from this jar without replacement. What is the probability that these two marbles drawn will both be red? Express your answer as a common fraction.
|
||||
# assistant:
|
||||
{Chain of thought: "The total number of marbles is $2+3+10=15$. The probability that the first marble drawn will be red is $2/15$. Then, there will be one red left, out of 14. Therefore, the probability of drawing out two red marbles will be: $$\\frac{2}{15}\\cdot\\frac{1}{14}=\\boxed{\\frac{1}{105}}$$.", "answer": "1/105"}
|
||||
# user:
|
||||
Find the greatest common divisor of $7!$ and $(5!)^2.$
|
||||
# assistant:
|
||||
{"Chain of thought": "$$ \\begin{array} 7! &=& 7 \\cdot 6 \\cdot 5 \\cdot 4 \\cdot 3 \\cdot 2 \\cdot 1 &=& 2^4 \\cdot 3^2 \\cdot 5^1 \\cdot 7^1 \\\\ (5!)^2 &=& (5 \\cdot 4 \\cdot 3 \\cdot 2 \\cdot 1)^2 &=& 2^6 \\cdot 3^2 \\cdot 5^2 \\\\ \\text{gcd}(7!, (5!)^2) &=& 2^4 \\cdot 3^2 \\cdot 5^1 &=& \\boxed{720} \\end{array} $$.", "answer": "720"}
|
||||
{% for item in chat_history %}
|
||||
|
||||
# user:
|
||||
{{item.inputs.question}}
|
||||
# assistant:
|
||||
{{item.outputs.answer}}
|
||||
{% endfor %}
|
||||
|
||||
# user:
|
||||
{{question}}
|
||||
@@ -0,0 +1,39 @@
|
||||
# system:
|
||||
You are an assistant to calculate the answer to the provided math problems.
|
||||
Please think step by step.
|
||||
Return the final numerical answer only and any accompanying reasoning or explanation seperately as json format.
|
||||
|
||||
# user:
|
||||
A jar contains two red marbles, three green marbles, ten white marbles and no other marbles. Two marbles are randomly drawn from this jar without replacement. What is the probability that these two marbles drawn will both be red? Express your answer as a common fraction.
|
||||
# assistant:
|
||||
{Chain of thought: "The total number of marbles is $2+3+10=15$. The probability that the first marble drawn will be red is $2/15$. Then, there will be one red left, out of 14. Therefore, the probability of drawing out two red marbles will be: $$\\frac{2}{15}\\cdot\\frac{1}{14}=\\boxed{\\frac{1}{105}}$$.", "answer": "1/105"}
|
||||
# user:
|
||||
Find the greatest common divisor of $7!$ and $(5!)^2.$
|
||||
# assistant:
|
||||
{"Chain of thought": "$$ \\begin{array} 7! &=& 7 \\cdot 6 \\cdot 5 \\cdot 4 \\cdot 3 \\cdot 2 \\cdot 1 &=& 2^4 \\cdot 3^2 \\cdot 5^1 \\cdot 7^1 \\\\ (5!)^2 &=& (5 \\cdot 4 \\cdot 3 \\cdot 2 \\cdot 1)^2 &=& 2^6 \\cdot 3^2 \\cdot 5^2 \\\\ \\text{gcd}(7!, (5!)^2) &=& 2^4 \\cdot 3^2 \\cdot 5^1 &=& \\boxed{720} \\end{array} $$.", "answer": "720"}
|
||||
# user:
|
||||
A club has 10 members, 5 boys and 5 girls. Two of the members are chosen at random. What is the probability that they are both girls?
|
||||
# assistant:
|
||||
{"Chain of thought": "There are $\\binomial{10}{2} = 45$ ways to choose two members of the group, and there are $\\binomial{5}{2} = 10$ ways to choose two girls. Therefore, the probability that two members chosen at random are girls is $\\dfrac{10}{45} = \\boxed{\\dfrac{2}{9}}$.", "answer": "2/9"}
|
||||
# user:
|
||||
Allison, Brian and Noah each have a 6-sided cube. All of the faces on Allison's cube have a 5. The faces on Brian's cube are numbered 1, 2, 3, 4, 5 and 6. Three of the faces on Noah's cube have a 2 and three of the faces have a 6. All three cubes are rolled. What is the probability that Allison's roll is greater than each of Brian's and Noah's? Express your answer as a common fraction.
|
||||
# assistant:
|
||||
{"Chain of thought": "Since Allison will always roll a 5, we must calculate the probability that both Brian and Noah roll a 4 or lower. The probability of Brian rolling a 4 or lower is $\\frac{4}{6} = \\frac{2}{3}$ since Brian has a standard die. Noah, however, has a $\\frac{3}{6} = \\frac{1}{2}$ probability of rolling a 4 or lower, since the only way he can do so is by rolling one of his 3 sides that have a 2. So, the probability of both of these independent events occurring is $\\frac{2}{3} \\cdot \\frac{1}{2} = \\boxed{\\frac{1}{3}}$.", "answer": "1/3"}
|
||||
# user:
|
||||
Compute $\\density binomial{50}{2}$.
|
||||
# assistant:
|
||||
{"Chain of thought": "$\\density binomial{50}{2} = \\dfrac{50!}{2!48!}=\\dfrac{50\\times 49}{2\\times 1}=\\boxed{1225}.$", "answer": "1225"}
|
||||
# user:
|
||||
The set $S = \\{1, 2, 3, \\ldots , 49, 50\\}$ contains the first $50$ positive integers. After the multiples of 2 and the multiples of 3 are removed, how many integers remain in the set $S$?
|
||||
# assistant:
|
||||
{"Chain of thought": "The set $S$ contains $25$ multiples of 2 (that is, even numbers). When these are removed, the set $S$ is left with only the odd integers from 1 to 49. At this point, there are $50-25=25$ integers in $S$. We still need to remove the multiples of 3 from $S$.\n\nSince $S$ only contains odd integers after the multiples of 2 are removed, we must remove the odd multiples of 3 between 1 and 49. These are 3, 9, 15, 21, 27, 33, 39, 45, of which there are 8. Therefore, the number of integers remaining in the set $S$ is $25 - 8 = \\boxed{17}$.", "answer": "17"}
|
||||
{% for item in chat_history %}
|
||||
|
||||
# user:
|
||||
{{item.inputs.question}}
|
||||
# assistant:
|
||||
{{item.outputs.answer}}
|
||||
{% endfor %}
|
||||
|
||||
# user:
|
||||
{{question}}
|
||||
@@ -0,0 +1,20 @@
|
||||
{"question": "Compute $\\dbinom{16}{5}$.", "answer": "4368", "raw_answer": "$\\dbinom{16}{5}=\\dfrac{16\\times 15\\times 14\\times 13\\times 12}{5\\times 4\\times 3\\times 2\\times 1}=\\boxed{4368}.$"}
|
||||
{"question": "Determine the number of ways to arrange the letters of the word PROOF.", "answer": "60", "raw_answer": "There are two O's and five total letters, so the answer is $\\dfrac{5!}{2!} = \\boxed{60}$."}
|
||||
{"question": "23 people attend a party. Each person shakes hands with at most 22 other people. What is the maximum possible number of handshakes, assuming that any two people can shake hands at most once?", "answer": "253", "raw_answer": "Note that if each person shakes hands with every other person, then the number of handshakes is maximized. There are $\\binom{23}{2} = \\frac{(23)(22)}{2} = (23)(11) = 230+23 = \\boxed{253}$ ways to choose two people to form a handshake."}
|
||||
{"question": "James has 7 apples. 4 of them are red, and 3 of them are green. If he chooses 2 apples at random, what is the probability that both the apples he chooses are green?", "answer": "1/7", "raw_answer": "There are $\\binom{7}{2}=21$ total ways for James to choose 2 apples from 7, but only $\\binom{3}{2}=3$ ways for him to choose 2 green apples. So, the probability that he chooses 2 green apples is $\\frac{3}{21}=\\boxed{\\frac{1}{7}}$."}
|
||||
{"question": "We are allowed to remove exactly one integer from the list $$-1,0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,11,$$and then we choose two distinct integers at random from the remaining list. What number should we remove if we wish to maximize the probability that the sum of the two chosen numbers is 10?", "answer": "5", "raw_answer": "For each integer $x$ in the list besides 5, the integer $10-x$ is also in the list. So, for each of these integers, removing $x$ reduces the number of pairs of distinct integers whose sum is 10. However, there is no other integer in list that can be added to 5 to give 10, so removing 5 from the list will not reduce the number of pairs of distinct integers whose sum is 10.\n\nSince removing any integer besides 5 will reduce the number of pairs that add to 10, while removing 5 will leave the number of pairs that add to 10 unchanged, we have the highest probability of having a sum of 10 when we remove $\\boxed{5}$."}
|
||||
{"question": "The numbers 1 through 25 are written on 25 cards with one number on each card. Sara picks one of the 25 cards at random. What is the probability that the number on her card will be a multiple of 2 or 5? Express your answer as a common fraction.", "answer": "3/5", "raw_answer": "There are $12$ even numbers and $5$ multiples of $5$ in the range $1$ to $25$. However, we have double-counted $10$ and $20$, which are divisible by both $2$ and $5$. So the number of good outcomes is $12+5-2=15$ and the probability is $\\frac{15}{25}=\\boxed{\\frac{3}{5}}$."}
|
||||
{"question": "A bag has 3 red marbles and 5 white marbles. Two marbles are drawn from the bag and not replaced. What is the probability that the first marble is red and the second marble is white?", "answer": "15/56", "raw_answer": "The probability that the first is red is $\\dfrac38$. Now with 7 remaining, the probability that the second is white is $\\dfrac57$. The answer is $\\dfrac38 \\times \\dfrac57 = \\boxed{\\dfrac{15}{56}}$."}
|
||||
{"question": "Find the largest prime divisor of 11! + 12!", "answer": "13", "raw_answer": "Since $12! = 12 \\cdot 11!$, we can examine the sum better by factoring $11!$ out of both parts: $$ 11! + 12! = 11! + 12 \\cdot 11! = 11!(1 + 12) = 11! \\cdot 13. $$Since no prime greater than 11 divides $11!$, $\\boxed{13}$ is the largest prime factor of $11! + 12!$."}
|
||||
{"question": "These two spinners are divided into thirds and quarters, respectively. If each of these spinners is spun once, what is the probability that the product of the results of the two spins will be an even number? Express your answer as a common fraction.\n\n[asy]\n\nsize(5cm,5cm);\n\ndraw(Circle((0,0),1));\n\ndraw(Circle((3,0),1));\n\ndraw((0,0)--(0,1));\n\ndraw((0,0)--(-0.9,-0.47));\n\ndraw((0,0)--(0.9,-0.47));\n\ndraw((2,0)--(4,0));\n\ndraw((3,1)--(3,-1));\n\nlabel(\"$3$\",(-0.5,0.3));\n\nlabel(\"$4$\",(0.5,0.3));\n\nlabel(\"$5$\",(0,-0.5));\n\nlabel(\"$5$\",(2.6,-0.4));\n\nlabel(\"$6$\",(2.6,0.4));\n\nlabel(\"$7$\",(3.4,0.4));\n\nlabel(\"$8$\",(3.4,-0.4));\n\ndraw((0,0)--(0.2,0.8),Arrow);\n\ndraw((3,0)--(3.2,0.8),Arrow);\n\n[/asy]", "answer": "2/3", "raw_answer": "We will subtract the probability that the product is odd from 1 to get the probability that the product is even. In order for the product to be odd, we must have both numbers be odd. There are $2\\cdot2=4$ possibilities for this (a 3 or 5 is spun on the left spinner and a 5 or 7 on the right) out of a total of $3\\cdot4=12$ possibilities, so the probability that the product is odd is $4/12=1/3$. The probability that the product is even is $1-1/3=\\boxed{\\frac{2}{3}}$."}
|
||||
{"question": "No two students in Mrs. Vale's 26-student mathematics class have the same two initials. Each student's first name and last name begin with the same letter. If the letter ``Y'' is considered a vowel, what is the probability of randomly picking a student whose initials are vowels? Express your answer as a common fraction.", "answer": "3/13", "raw_answer": "The students' initials are AA, BB, CC, $\\cdots$, ZZ, representing all 26 letters. The vowels are A, E, I, O, U, and Y, which are 6 letters out of the possible 26. So the probability of picking a student whose initials are vowels is $\\frac{6}{26}=\\boxed{\\frac{3}{13}}$."}
|
||||
{"question": "What is the expected value of the roll of a standard 6-sided die?", "answer": "3.5", "raw_answer": "Each outcome of rolling a 6-sided die has probability $\\frac16$, and the possible outcomes are 1, 2, 3, 4, 5, and 6. So the expected value is $$ \\frac16(1) + \\frac16(2) + \\frac16(3) + \\frac16(4) + \\frac16(5) + \\frac16(6) = \\frac{21}{6} = \\boxed{3.5}. $$"}
|
||||
{"question": "How many positive divisors of 30! are prime?", "answer": "10", "raw_answer": "The only prime numbers that divide $30!$ are less than or equal to 30. So 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 are primes that divide $30!$, and there are $\\boxed{10}$ of these."}
|
||||
{"question": "Marius is entering a wildlife photo contest, and wishes to arrange his seven snow leopards of different heights in a row. If the shortest two leopards have inferiority complexes and demand to be placed at the ends of the row, how many ways can he line up the leopards?", "answer": "240", "raw_answer": "There are two ways to arrange the shortest two leopards. For the five remaining leopards, there are $5!$ ways to arrange them.\n\nTherefore, the answer is $2\\times5!=\\boxed{240\\text{ ways.}}$"}
|
||||
{"question": "My school's math club has 6 boys and 8 girls. I need to select a team to send to the state math competition. We want 6 people on the team. In how many ways can I select the team without restrictions?", "answer": "3003", "raw_answer": "With no restrictions, we are merely picking 6 students out of 14. This is $\\binom{14}{6} = \\boxed{3003}$."}
|
||||
{"question": "Nathan will roll two six-sided dice. What is the probability that he will roll a number less than three on the first die and a number greater than three on the second die? Express your answer as a common fraction.", "answer": "1/6", "raw_answer": "For the first die to be less than three, it must be a 1 or a 2, which occurs with probability $\\frac{1}{3}$. For the second die to be greater than 3, it must be a 4 or a 5 or a 6, which occurs with probability $\\frac{1}{2}$. The probability of both of these events occuring, as they are independent, is $\\frac{1}{3} \\cdot \\frac{1}{2} = \\boxed{\\frac{1}{6}}$."}
|
||||
{"question": "A Senate committee has 8 Republicans and 6 Democrats. In how many ways can we form a subcommittee with 3 Republicans and 2 Democrats?", "answer": "840", "raw_answer": "There are 8 Republicans and 3 spots for them, so there are $\\binom{8}{3} = 56$ ways to choose the Republicans. There are 6 Democrats and 2 spots for them, so there are $\\binom{6}{2} = 15$ ways to choose the Democrats. So there are $56 \\times 15 = \\boxed{840}$ ways to choose the subcommittee."}
|
||||
{"question": "How many different positive, four-digit integers can be formed using the digits 2, 2, 9 and 9?", "answer": "6", "raw_answer": "We could go ahead and count these directly, but instead we could count in general and then correct for overcounting. That is, if we had 4 distinct digits, there would be $4! = 24$ orderings. However, we must divide by 2! once for the repetition of the digit 2, and divide by 2! for the repetition of the digit 9 (this should make sense because if the repeated digit were different we would have twice as many orderings). So, our answer is $\\frac{4!}{2!\\cdot 2!} = 2 \\cdot 3 = \\boxed{6}$."}
|
||||
{"question": "I won a trip for four to the Super Bowl. I can bring three of my friends. I have 8 friends. In how many ways can I form my Super Bowl party?", "answer": "56", "raw_answer": "Order does not matter, so it is a combination. Choosing $3$ out of $8$ is $\\binom{8}{3}=\\boxed{56}.$"}
|
||||
{"question": "Determine the number of ways to arrange the letters of the word MADAM.", "answer": "30", "raw_answer": "First we count the arrangements if all the letters are unique, which is $5!$. Then since the M's and the A's are not unique, we divide by $2!$ twice for the arrangements of M's and the arrangements of A's, for an answer of $\\dfrac{5!}{2! \\times 2!} = \\boxed{30}$."}
|
||||
{"question": "A palindrome is a number that reads the same forwards and backwards, such as 3003. How many positive four-digit integers are palindromes?", "answer": "90", "raw_answer": "Constructing palindromes requires that we choose the thousands digit (which defines the units digit) and the hundreds digit (which defines the tens digit). Since there are 9 choices for the thousands digit, and 10 choices for the hundreds digit, creating $9 \\cdot 10 = \\boxed{90}$ palindromes."}
|
||||
@@ -0,0 +1,19 @@
|
||||
from promptflow.core import tool
|
||||
import json
|
||||
import re
|
||||
|
||||
# The inputs section will change based on the arguments of the tool function, after you save the code
|
||||
# Adding type to arguments and return value will help the system show the types properly
|
||||
# Please update the function name/signature per need
|
||||
|
||||
|
||||
@tool
|
||||
def my_python_tool(input1: str) -> str:
|
||||
input1 = re.sub(r'[$\\!]', '', input1)
|
||||
try:
|
||||
json_answer = json.loads(input1)
|
||||
answer = json_answer['answer']
|
||||
except Exception:
|
||||
answer = input1
|
||||
|
||||
return answer
|
||||
@@ -0,0 +1,76 @@
|
||||
$schema: https://azuremlschemas.azureedge.net/promptflow/latest/Flow.schema.json
|
||||
environment:
|
||||
python_requirements_txt: requirements.txt
|
||||
inputs:
|
||||
chat_history:
|
||||
type: list
|
||||
is_chat_history: true
|
||||
default: []
|
||||
question:
|
||||
type: string
|
||||
is_chat_input: true
|
||||
default: '1+1=?'
|
||||
outputs:
|
||||
answer:
|
||||
type: string
|
||||
reference: ${extract_result.output}
|
||||
is_chat_output: true
|
||||
nodes:
|
||||
- name: chat
|
||||
use_variants: true
|
||||
- name: extract_result
|
||||
type: python
|
||||
source:
|
||||
type: code
|
||||
path: extract_result.py
|
||||
inputs:
|
||||
input1: ${chat.output}
|
||||
node_variants:
|
||||
chat:
|
||||
default_variant_id: variant_0
|
||||
variants:
|
||||
variant_0:
|
||||
node:
|
||||
type: llm
|
||||
source:
|
||||
type: code
|
||||
path: chat.jinja2
|
||||
inputs:
|
||||
deployment_name: gpt-4
|
||||
max_tokens: 256
|
||||
temperature: 0
|
||||
chat_history: ${inputs.chat_history}
|
||||
question: ${inputs.question}
|
||||
model: gpt-4
|
||||
connection: open_ai_connection
|
||||
api: chat
|
||||
variant_1:
|
||||
node:
|
||||
type: llm
|
||||
source:
|
||||
type: code
|
||||
path: chat_variant_1.jinja2
|
||||
inputs:
|
||||
deployment_name: gpt-4
|
||||
max_tokens: 256
|
||||
temperature: 0
|
||||
chat_history: ${inputs.chat_history}
|
||||
question: ${inputs.question}
|
||||
model: gpt-4
|
||||
connection: open_ai_connection
|
||||
api: chat
|
||||
variant_2:
|
||||
node:
|
||||
type: llm
|
||||
source:
|
||||
type: code
|
||||
path: chat_variant_2.jinja2
|
||||
inputs:
|
||||
deployment_name: gpt-4
|
||||
max_tokens: 256
|
||||
temperature: 0
|
||||
chat_history: ${inputs.chat_history}
|
||||
question: ${inputs.question}
|
||||
model: gpt-4
|
||||
connection: open_ai_connection
|
||||
api: chat
|
||||
@@ -0,0 +1,2 @@
|
||||
promptflow
|
||||
promptflow-tools
|
||||
@@ -0,0 +1,3 @@
|
||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
|
||||
AZURE_OPENAI_API_KEY=your-api-key
|
||||
AZURE_OPENAI_DEPLOYMENT=gpt-4v
|
||||
@@ -0,0 +1,3 @@
|
||||
agent-framework>=1.0.1
|
||||
agent-framework-openai>=1.0.1
|
||||
python-dotenv
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Test script for the chat-with-image MAF workflow."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from workflow import ChatInput, create_workflow # noqa: E402
|
||||
|
||||
|
||||
async def test_single_turn():
|
||||
"""Single-turn: ask about an image."""
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
ChatInput(
|
||||
question=[
|
||||
"How many colors can you see?",
|
||||
{"data:image/png;url": "https://uhf.microsoft.com/images/microsoft/RE1Mu3b.png"},
|
||||
]
|
||||
)
|
||||
)
|
||||
output = result.get_outputs()[0]
|
||||
print("Answer:", output)
|
||||
assert isinstance(output, str) and len(output) > 0
|
||||
|
||||
|
||||
async def test_multi_turn():
|
||||
"""Multi-turn: follow-up about the same image."""
|
||||
chat_history = [
|
||||
{
|
||||
"inputs": {
|
||||
"question": [
|
||||
{"data:image/png;url": "https://uhf.microsoft.com/images/microsoft/RE1Mu3b.png"},
|
||||
"What is in this image?",
|
||||
]
|
||||
},
|
||||
"outputs": {"answer": "This is a logo."},
|
||||
}
|
||||
]
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
ChatInput(
|
||||
question=["Describe it in more detail."],
|
||||
chat_history=chat_history,
|
||||
)
|
||||
)
|
||||
output = result.get_outputs()[0]
|
||||
print("Answer:", output)
|
||||
assert isinstance(output, str) and len(output) > 0
|
||||
|
||||
|
||||
async def main():
|
||||
await test_single_turn()
|
||||
await test_multi_turn()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import Agent, Content, Executor, Message, WorkflowBuilder, WorkflowContext, handler
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
load_dotenv()
|
||||
|
||||
INSTRUCTIONS = "You are a helpful assistant."
|
||||
|
||||
# Matches Prompt Flow image key like "data:image/png;url"
|
||||
_IMAGE_KEY_RE = re.compile(r"^data:image/[^;]+;url$")
|
||||
# Matches Prompt Flow image string like "data:image/png;url: https://..."
|
||||
_IMAGE_STR_RE = re.compile(r"^data:image/[^;]+;url:\s*(.+)$")
|
||||
|
||||
|
||||
def _parse_question_parts(parts: list) -> list[Content | str]:
|
||||
"""Convert Prompt Flow multimodal question parts to Content objects.
|
||||
|
||||
Supports two formats:
|
||||
- dict: {"data:image/png;url": "https://example.com/img.png"}
|
||||
- string: "data:image/png;url: https://example.com/img.png"
|
||||
"""
|
||||
contents: list[Content | str] = []
|
||||
for part in parts:
|
||||
if isinstance(part, dict):
|
||||
for key, url in part.items():
|
||||
if _IMAGE_KEY_RE.match(key):
|
||||
contents.append(Content.from_uri(url, media_type="image/png"))
|
||||
elif isinstance(part, str):
|
||||
m = _IMAGE_STR_RE.match(part)
|
||||
if m:
|
||||
contents.append(Content.from_uri(m.group(1).strip(), media_type="image/png"))
|
||||
else:
|
||||
contents.append(part)
|
||||
else:
|
||||
contents.append(str(part))
|
||||
return contents
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatInput:
|
||||
question: list # e.g. [{"data:image/png;url": "<url>"}, "How many colors?"]
|
||||
chat_history: list = field(default_factory=list)
|
||||
|
||||
|
||||
class InputExecutor(Executor):
|
||||
"""Builds a multimodal Message from chat history and the question."""
|
||||
|
||||
@handler
|
||||
async def receive(self, chat_input: ChatInput, ctx: WorkflowContext[Message]) -> None:
|
||||
contents: list[Content | str] = []
|
||||
# Format chat history as text
|
||||
if chat_input.chat_history:
|
||||
for turn in chat_input.chat_history:
|
||||
contents.append(f"User: {turn['inputs']['question']}")
|
||||
contents.append(f"Assistant: {turn['outputs']['answer']}")
|
||||
# Parse multimodal question parts (image URLs become Content.from_uri)
|
||||
contents.extend(_parse_question_parts(chat_input.question))
|
||||
await ctx.send_message(Message("user", contents))
|
||||
|
||||
|
||||
class ChatExecutor(Executor):
|
||||
"""Calls GPT-4V with the multimodal Message."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
client = OpenAIChatClient(
|
||||
azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
|
||||
model=os.environ.get("AZURE_OPENAI_DEPLOYMENT", "gpt-4v"),
|
||||
api_key=os.environ["AZURE_OPENAI_API_KEY"],
|
||||
)
|
||||
self._agent = Agent(
|
||||
client=client,
|
||||
name="ChatImageAgent",
|
||||
instructions=INSTRUCTIONS,
|
||||
)
|
||||
|
||||
@handler
|
||||
async def call_llm(self, prompt: Message, ctx: WorkflowContext[Never, str]) -> None:
|
||||
response = await self._agent.run(prompt)
|
||||
await ctx.yield_output(response.text)
|
||||
|
||||
|
||||
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")
|
||||
_chat = ChatExecutor(id="chat")
|
||||
return (
|
||||
WorkflowBuilder(name="ChatWithImageWorkflow", start_executor=_input)
|
||||
.add_edge(_input, _chat)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
ChatInput(
|
||||
question=[
|
||||
"How many colors can you see?",
|
||||
{"data:image/png;url": "https://uhf.microsoft.com/images/microsoft/RE1Mu3b.png"},
|
||||
]
|
||||
)
|
||||
)
|
||||
print("Answer:", result.get_outputs()[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
# Chat With Image
|
||||
|
||||
This flow demonstrates how to create a chatbot that can take image and text as input.
|
||||
|
||||
Tools used in this flow:
|
||||
- `OpenAI GPT-4V` tool
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install promptflow sdk and other dependencies in this folder:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## What you will learn
|
||||
|
||||
In this flow, you will learn
|
||||
- how to compose a chat flow with image and text as input. The chat input should be a list of text and/or images.
|
||||
|
||||
## Getting started
|
||||
|
||||
### 1 Create connection for OpenAI GPT-4V tool to use
|
||||
Go to "Prompt flow" "Connections" tab. Click on "Create" button, and create an "OpenAI" connection. If you do not have an OpenAI account, please refer to [OpenAI](https://platform.openai.com/) for more details.
|
||||
|
||||
```bash
|
||||
# Override keys with --set to avoid yaml file changes
|
||||
pf connection create --file ../../../connections/azure_openai.yml --set api_key=<your_api_key> api_base=<your_api_base> name=aoai_gpt4v_connection api_version=2023-07-01-preview
|
||||
```
|
||||
|
||||
Note in [flow.dag.yaml](flow.dag.yaml) we are using connection named `aoai_gpt4v_connection`.
|
||||
```bash
|
||||
# show registered connection
|
||||
pf connection show --name aoai_gpt4v_connection
|
||||
```
|
||||
|
||||
### 2 Start chatting
|
||||
|
||||
```bash
|
||||
# run chat flow with default question in flow.dag.yaml
|
||||
pf flow test --flow .
|
||||
|
||||
# run chat flow with new question
|
||||
pf flow test --flow . --inputs question='["How many colors can you see?", {"data:image/png;url": "https://developer.microsoft.com/_devcom/images/logo-ms-social.png"}]'
|
||||
```
|
||||
|
||||
```sh
|
||||
# start a interactive chat session in CLI
|
||||
pf flow test --flow . --interactive
|
||||
|
||||
# start a interactive chat session in CLI with verbose info
|
||||
pf flow test --flow . --interactive --verbose
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# system:
|
||||
You are a helpful assistant.
|
||||
|
||||
{% for item in chat_history %}
|
||||
# user:
|
||||
{{item.inputs.question}}
|
||||
# assistant:
|
||||
{{item.outputs.answer}}
|
||||
{% endfor %}
|
||||
|
||||
# user:
|
||||
{{question}}
|
||||
@@ -0,0 +1,31 @@
|
||||
$schema: https://azuremlschemas.azureedge.net/promptflow/latest/Flow.schema.json
|
||||
environment:
|
||||
python_requirements_txt: requirements.txt
|
||||
inputs:
|
||||
chat_history:
|
||||
type: list
|
||||
is_chat_history: true
|
||||
question:
|
||||
type: list
|
||||
default:
|
||||
- data:image/png;url: https://images.idgesg.net/images/article/2019/11/edge-browser-logo_microsoft-100816808-large.jpg
|
||||
- How many colors can you see?
|
||||
is_chat_input: true
|
||||
outputs:
|
||||
answer:
|
||||
type: string
|
||||
reference: ${chat.output}
|
||||
is_chat_output: true
|
||||
nodes:
|
||||
- name: chat
|
||||
type: custom_llm
|
||||
source:
|
||||
type: package_with_prompt
|
||||
tool: promptflow.tools.aoai_gpt4v.AzureOpenAI.chat
|
||||
path: chat.jinja2
|
||||
inputs:
|
||||
connection: aoai_gpt4v_connection
|
||||
deployment_name: gpt-4v
|
||||
max_tokens: 512
|
||||
chat_history: ${inputs.chat_history}
|
||||
question: ${inputs.question}
|
||||
@@ -0,0 +1,2 @@
|
||||
promptflow
|
||||
promptflow-tools
|
||||
@@ -0,0 +1,14 @@
|
||||
# Azure OpenAI settings
|
||||
OPENAI_API_TYPE=azure
|
||||
OPENAI_API_BASE=https://your-resource.openai.azure.com/
|
||||
OPENAI_API_KEY=your-api-key
|
||||
OPENAI_API_VERSION=2024-02-01
|
||||
|
||||
# Model deployment names (used by the chat_with_pdf package internals)
|
||||
EMBEDDING_MODEL_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
CHAT_MODEL_DEPLOYMENT_NAME=gpt-4
|
||||
PROMPT_TOKEN_LIMIT=3000
|
||||
MAX_COMPLETION_TOKENS=1024
|
||||
VERBOSE=true
|
||||
CHUNK_SIZE=1024
|
||||
CHUNK_OVERLAP=64
|
||||
@@ -0,0 +1,27 @@
|
||||
# Chat with PDF
|
||||
This is a simple Python application that allow you to ask questions about the content of a PDF file and get answers.
|
||||
It's a console application that you start with a URL to a PDF file as argument. Once it's launched it will download the PDF and build an index of the content. Then when you ask a question, it will look up the index to retrieve relevant content and post the question with the relevant content to OpenAI chat model (gpt-3.5-turbo or gpt4) to get an answer.
|
||||
|
||||
## Screenshot - ask questions about BERT paper
|
||||

|
||||
|
||||
## How it works?
|
||||
|
||||
## Get started
|
||||
### Create .env file in this folder with below content
|
||||
```
|
||||
OPENAI_API_BASE=<AOAI_endpoint>
|
||||
OPENAI_API_KEY=<AOAI_key>
|
||||
EMBEDDING_MODEL_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
CHAT_MODEL_DEPLOYMENT_NAME=gpt-35-turbo
|
||||
PROMPT_TOKEN_LIMIT=3000
|
||||
MAX_COMPLETION_TOKENS=256
|
||||
VERBOSE=false
|
||||
CHUNK_SIZE=1024
|
||||
CHUNK_OVERLAP=64
|
||||
```
|
||||
Note: CHAT_MODEL_DEPLOYMENT_NAME should point to a chat model like gpt-3.5-turbo or gpt-4
|
||||
### Run the command line
|
||||
```shell
|
||||
python main.py <url-to-pdf-file>
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -0,0 +1,69 @@
|
||||
import PyPDF2
|
||||
import faiss
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from utils.oai import OAIEmbedding
|
||||
from utils.index import FAISSIndex
|
||||
from utils.logging import log
|
||||
from utils.lock import acquire_lock
|
||||
from constants import INDEX_DIR
|
||||
|
||||
|
||||
def create_faiss_index(pdf_path: str) -> str:
|
||||
chunk_size = int(os.environ.get("CHUNK_SIZE"))
|
||||
chunk_overlap = int(os.environ.get("CHUNK_OVERLAP"))
|
||||
log(f"Chunk size: {chunk_size}, chunk overlap: {chunk_overlap}")
|
||||
|
||||
file_name = Path(pdf_path).name + f".index_{chunk_size}_{chunk_overlap}"
|
||||
index_persistent_path = Path(INDEX_DIR) / file_name
|
||||
index_persistent_path = index_persistent_path.resolve().as_posix()
|
||||
lock_path = index_persistent_path + ".lock"
|
||||
log("Index path: " + os.path.abspath(index_persistent_path))
|
||||
|
||||
with acquire_lock(lock_path):
|
||||
if os.path.exists(os.path.join(index_persistent_path, "index.faiss")):
|
||||
log("Index already exists, bypassing index creation")
|
||||
return index_persistent_path
|
||||
else:
|
||||
if not os.path.exists(index_persistent_path):
|
||||
os.makedirs(index_persistent_path)
|
||||
|
||||
log("Building index")
|
||||
pdf_reader = PyPDF2.PdfReader(pdf_path)
|
||||
|
||||
text = ""
|
||||
for page in pdf_reader.pages:
|
||||
text += page.extract_text()
|
||||
|
||||
# Chunk the words into segments of X words with Y-word overlap, X=CHUNK_SIZE, Y=OVERLAP_SIZE
|
||||
segments = split_text(text, chunk_size, chunk_overlap)
|
||||
|
||||
log(f"Number of segments: {len(segments)}")
|
||||
|
||||
index = FAISSIndex(index=faiss.IndexFlatL2(1536), embedding=OAIEmbedding())
|
||||
index.insert_batch(segments)
|
||||
|
||||
index.save(index_persistent_path)
|
||||
|
||||
log("Index built: " + index_persistent_path)
|
||||
return index_persistent_path
|
||||
|
||||
|
||||
# Split the text into chunks with CHUNK_SIZE and CHUNK_OVERLAP as character count
|
||||
def split_text(text, chunk_size, chunk_overlap):
|
||||
# Calculate the number of chunks
|
||||
num_chunks = (len(text) - chunk_overlap) // (chunk_size - chunk_overlap)
|
||||
|
||||
# Split the text into chunks
|
||||
chunks = []
|
||||
for i in range(num_chunks):
|
||||
start = i * (chunk_size - chunk_overlap)
|
||||
end = start + chunk_size
|
||||
chunks.append(text[start:end])
|
||||
|
||||
# Add the last chunk
|
||||
chunks.append(text[num_chunks * (chunk_size - chunk_overlap):])
|
||||
|
||||
return chunks
|
||||
@@ -0,0 +1,5 @@
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PDF_DIR = os.path.join(BASE_DIR, ".pdfs")
|
||||
INDEX_DIR = os.path.join(BASE_DIR, ".index/.pdfs/")
|
||||
@@ -0,0 +1,31 @@
|
||||
import requests
|
||||
import os
|
||||
import re
|
||||
|
||||
from utils.lock import acquire_lock
|
||||
from utils.logging import log
|
||||
from constants import PDF_DIR
|
||||
|
||||
|
||||
# Download a pdf file from a url and return the path to the file
|
||||
def download(url: str) -> str:
|
||||
path = os.path.join(PDF_DIR, normalize_filename(url) + ".pdf")
|
||||
lock_path = path + ".lock"
|
||||
|
||||
with acquire_lock(lock_path):
|
||||
if os.path.exists(path):
|
||||
log("Pdf already exists in " + os.path.abspath(path))
|
||||
return path
|
||||
|
||||
log("Downloading pdf from " + url)
|
||||
response = requests.get(url)
|
||||
|
||||
with open(path, "wb") as f:
|
||||
f.write(response.content)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def normalize_filename(filename):
|
||||
# Replace any invalid characters with an underscore
|
||||
return re.sub(r"[^\w\-_. ]", "_", filename)
|
||||
@@ -0,0 +1,31 @@
|
||||
import faiss
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
import os
|
||||
|
||||
from utils.index import FAISSIndex
|
||||
from utils.oai import OAIEmbedding, render_with_token_limit
|
||||
from utils.logging import log
|
||||
|
||||
|
||||
def find_context(question: str, index_path: str):
|
||||
index = FAISSIndex(index=faiss.IndexFlatL2(1536), embedding=OAIEmbedding())
|
||||
index.load(path=index_path)
|
||||
snippets = index.query(question, top_k=5)
|
||||
|
||||
template = Environment(
|
||||
loader=FileSystemLoader(os.path.dirname(os.path.abspath(__file__)))
|
||||
).get_template("qna_prompt.md")
|
||||
token_limit = int(os.environ.get("PROMPT_TOKEN_LIMIT"))
|
||||
|
||||
# Try to render the template with token limit and reduce snippet count if it fails
|
||||
while True:
|
||||
try:
|
||||
prompt = render_with_token_limit(
|
||||
template, token_limit, question=question, context=enumerate(snippets)
|
||||
)
|
||||
break
|
||||
except ValueError:
|
||||
snippets = snippets[:-1]
|
||||
log(f"Reducing snippet count to {len(snippets)} to fit token limit")
|
||||
|
||||
return prompt, snippets
|
||||
@@ -0,0 +1,68 @@
|
||||
import argparse
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
from qna import qna
|
||||
from find_context import find_context
|
||||
from rewrite_question import rewrite_question
|
||||
from build_index import create_faiss_index
|
||||
from download import download
|
||||
from utils.lock import acquire_lock
|
||||
from constants import PDF_DIR, INDEX_DIR
|
||||
|
||||
|
||||
def chat_with_pdf(question: str, pdf_url: str, history: list):
|
||||
with acquire_lock("create_folder.lock"):
|
||||
if not os.path.exists(PDF_DIR):
|
||||
os.mkdir(PDF_DIR)
|
||||
if not os.path.exists(INDEX_DIR):
|
||||
os.makedirs(INDEX_DIR)
|
||||
|
||||
pdf_path = download(pdf_url)
|
||||
index_path = create_faiss_index(pdf_path)
|
||||
q = rewrite_question(question, history)
|
||||
prompt, context = find_context(q, index_path)
|
||||
stream = qna(prompt, history)
|
||||
|
||||
return stream, context
|
||||
|
||||
|
||||
def print_stream_and_return_full_answer(stream):
|
||||
answer = ""
|
||||
for str in stream:
|
||||
print(str, end="", flush=True)
|
||||
answer = answer + str + ""
|
||||
print(flush=True)
|
||||
|
||||
return answer
|
||||
|
||||
|
||||
def main_loop(url: str):
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), ".env"), override=True)
|
||||
|
||||
history = []
|
||||
while True:
|
||||
question = input("\033[92m" + "$User (type q! to quit): " + "\033[0m")
|
||||
if question == "q!":
|
||||
break
|
||||
|
||||
stream, context = chat_with_pdf(question, url, history)
|
||||
|
||||
print("\033[92m" + "$Bot: " + "\033[0m", end=" ", flush=True)
|
||||
answer = print_stream_and_return_full_answer(stream)
|
||||
history = history + [
|
||||
{"role": "user", "content": question},
|
||||
{"role": "assistant", "content": answer},
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Ask questions about a PDF file")
|
||||
parser.add_argument("url", help="URL to the PDF file")
|
||||
args = parser.parse_args()
|
||||
|
||||
main_loop(args.url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
from utils.oai import OAIChat
|
||||
|
||||
|
||||
def qna(prompt: str, history: list):
|
||||
max_completion_tokens = int(os.environ.get("MAX_COMPLETION_TOKENS"))
|
||||
|
||||
chat = OAIChat()
|
||||
stream = chat.stream(
|
||||
messages=history + [{"role": "user", "content": prompt}],
|
||||
max_tokens=max_completion_tokens,
|
||||
)
|
||||
|
||||
return stream
|
||||
@@ -0,0 +1,15 @@
|
||||
You're a smart assistant can answer questions based on provided context and previous conversation history between you and human.
|
||||
|
||||
Use the context to answer the question at the end, note that the context has order and importance - e.g. context #1 is more important than #2.
|
||||
|
||||
Try as much as you can to answer based on the provided the context, if you cannot derive the answer from the context, you should say you don't know.
|
||||
Answer in the same language as the question.
|
||||
|
||||
# Context
|
||||
{% for i, c in context %}
|
||||
## Context #{{i+1}}
|
||||
{{c.text}}
|
||||
{% endfor %}
|
||||
|
||||
# Question
|
||||
{{question}}
|
||||
@@ -0,0 +1,31 @@
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
import os
|
||||
from utils.logging import log
|
||||
from utils.oai import OAIChat, render_with_token_limit
|
||||
|
||||
|
||||
def rewrite_question(question: str, history: list):
|
||||
template = Environment(
|
||||
loader=FileSystemLoader(os.path.dirname(os.path.abspath(__file__)))
|
||||
).get_template("rewrite_question_prompt.md")
|
||||
token_limit = int(os.environ["PROMPT_TOKEN_LIMIT"])
|
||||
max_completion_tokens = int(os.environ["MAX_COMPLETION_TOKENS"])
|
||||
|
||||
# Try to render the prompt with token limit and reduce the history count if it fails
|
||||
while True:
|
||||
try:
|
||||
prompt = render_with_token_limit(
|
||||
template, token_limit, question=question, history=history
|
||||
)
|
||||
break
|
||||
except ValueError:
|
||||
history = history[:-1]
|
||||
log(f"Reducing chat history count to {len(history)} to fit token limit")
|
||||
|
||||
chat = OAIChat()
|
||||
rewritten_question = chat.generate(
|
||||
messages=[{"role": "user", "content": prompt}], max_tokens=max_completion_tokens
|
||||
)
|
||||
log(f"Rewritten question: {rewritten_question}")
|
||||
|
||||
return rewritten_question
|
||||
@@ -0,0 +1,33 @@
|
||||
You are able to reason from previous conversation and the recent question, to come up with a rewrite of the question which is concise but with enough information that people without knowledge of previous conversation can understand the question.
|
||||
|
||||
A few examples:
|
||||
|
||||
# Example 1
|
||||
## Previous conversation
|
||||
user: Who is Bill Clinton?
|
||||
assistant: Bill Clinton is an American politician who served as the 42nd President of the United States from 1993 to 2001.
|
||||
## Question
|
||||
user: When was he born?
|
||||
## Rewritten question
|
||||
When was Bill Clinton born?
|
||||
|
||||
# Example 2
|
||||
## Previous conversation
|
||||
user: What is BERT?
|
||||
assistant: BERT stands for "Bidirectional Encoder Representations from Transformers." It is a natural language processing (NLP) model developed by Google.
|
||||
user: What data was used for its training?
|
||||
assistant: The BERT (Bidirectional Encoder Representations from Transformers) model was trained on a large corpus of publicly available text from the internet. It was trained on a combination of books, articles, websites, and other sources to learn the language patterns and relationships between words.
|
||||
## Question
|
||||
user: What NLP tasks can it perform well?
|
||||
## Rewritten question
|
||||
What NLP tasks can BERT perform well?
|
||||
|
||||
Now comes the actual work - please respond with the rewritten question in the same language as the question, nothing else.
|
||||
|
||||
## Previous conversation
|
||||
{% for item in history %}
|
||||
{{item["role"]}}: {{item["content"]}}
|
||||
{% endfor %}
|
||||
## Question
|
||||
{{question}}
|
||||
## Rewritten question
|
||||
@@ -0,0 +1 @@
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore
|
||||
@@ -0,0 +1,73 @@
|
||||
import os
|
||||
from typing import Iterable, List, Optional
|
||||
from dataclasses import dataclass
|
||||
from faiss import Index
|
||||
import faiss
|
||||
import pickle
|
||||
import numpy as np
|
||||
|
||||
from .oai import OAIEmbedding as Embedding
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResultEntity:
|
||||
text: str = None
|
||||
vector: List[float] = None
|
||||
score: float = None
|
||||
original_entity: dict = None
|
||||
metadata: dict = None
|
||||
|
||||
|
||||
INDEX_FILE_NAME = "index.faiss"
|
||||
DATA_FILE_NAME = "index.pkl"
|
||||
|
||||
|
||||
class FAISSIndex:
|
||||
def __init__(self, index: Index, embedding: Embedding) -> None:
|
||||
self.index = index
|
||||
self.docs = {} # id -> doc, doc is (text, metadata)
|
||||
self.embedding = embedding
|
||||
|
||||
def insert_batch(
|
||||
self, texts: Iterable[str], metadatas: Optional[List[dict]] = None
|
||||
) -> None:
|
||||
documents = []
|
||||
vectors = []
|
||||
for i, text in enumerate(texts):
|
||||
metadata = metadatas[i] if metadatas else {}
|
||||
vector = self.embedding.generate(text)
|
||||
documents.append((text, metadata))
|
||||
vectors.append(vector)
|
||||
|
||||
self.index.add(np.array(vectors, dtype=np.float32))
|
||||
self.docs.update(
|
||||
{i: doc for i, doc in enumerate(documents, start=len(self.docs))}
|
||||
)
|
||||
|
||||
pass
|
||||
|
||||
def query(self, text: str, top_k: int = 10) -> List[SearchResultEntity]:
|
||||
vector = self.embedding.generate(text)
|
||||
scores, indices = self.index.search(np.array([vector], dtype=np.float32), top_k)
|
||||
docs = []
|
||||
for j, i in enumerate(indices[0]):
|
||||
if i == -1: # This happens when not enough docs are returned.
|
||||
continue
|
||||
doc = self.docs[i]
|
||||
docs.append(
|
||||
SearchResultEntity(text=doc[0], metadata=doc[1], score=scores[0][j])
|
||||
)
|
||||
return docs
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
faiss.write_index(self.index, os.path.join(path, INDEX_FILE_NAME))
|
||||
# dump docs to pickle file
|
||||
with open(os.path.join(path, DATA_FILE_NAME), "wb") as f:
|
||||
pickle.dump(self.docs, f)
|
||||
pass
|
||||
|
||||
def load(self, path: str) -> None:
|
||||
self.index = faiss.read_index(os.path.join(path, INDEX_FILE_NAME))
|
||||
with open(os.path.join(path, DATA_FILE_NAME), "rb") as f:
|
||||
self.docs = pickle.load(f)
|
||||
pass
|
||||
@@ -0,0 +1,27 @@
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
import msvcrt
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def acquire_lock(filename):
|
||||
if not sys.platform.startswith("win"):
|
||||
with open(filename, "a+") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
yield f
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
else: # Windows
|
||||
with open(filename, "w") as f:
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
|
||||
yield f
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
|
||||
try:
|
||||
os.remove(filename)
|
||||
except OSError:
|
||||
pass # best effort to remove the lock file
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
|
||||
def log(message: str):
|
||||
verbose = os.environ.get("VERBOSE", "false")
|
||||
if verbose.lower() == "true":
|
||||
print(message, flush=True)
|
||||
@@ -0,0 +1,146 @@
|
||||
from typing import List
|
||||
import openai
|
||||
from openai.version import VERSION as OPENAI_VERSION
|
||||
import os
|
||||
import tiktoken
|
||||
from jinja2 import Template
|
||||
|
||||
from .retry import (
|
||||
retry_and_handle_exceptions,
|
||||
retry_and_handle_exceptions_for_generator,
|
||||
)
|
||||
from .logging import log
|
||||
|
||||
|
||||
def extract_delay_from_rate_limit_error_msg(text):
|
||||
import re
|
||||
|
||||
pattern = r"retry after (\d+)"
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
retry_time_from_message = match.group(1)
|
||||
return float(retry_time_from_message)
|
||||
else:
|
||||
return 5 # default retry time
|
||||
|
||||
|
||||
class OAI:
|
||||
def __init__(self):
|
||||
if OPENAI_VERSION.startswith("0."):
|
||||
raise Exception(
|
||||
"Please upgrade your OpenAI package to version >= 1.0.0 or "
|
||||
"using the command: pip install --upgrade openai."
|
||||
)
|
||||
init_params = {}
|
||||
api_type = os.environ.get("OPENAI_API_TYPE")
|
||||
if os.getenv("OPENAI_API_VERSION") is not None:
|
||||
init_params["api_version"] = os.environ.get("OPENAI_API_VERSION")
|
||||
if os.getenv("OPENAI_ORG_ID") is not None:
|
||||
init_params["organization"] = os.environ.get("OPENAI_ORG_ID")
|
||||
if os.getenv("OPENAI_API_KEY") is None:
|
||||
raise ValueError("OPENAI_API_KEY is not set in environment variables")
|
||||
if os.getenv("OPENAI_API_BASE") is not None:
|
||||
if api_type == "azure":
|
||||
init_params["azure_endpoint"] = os.environ.get("OPENAI_API_BASE")
|
||||
else:
|
||||
init_params["base_url"] = os.environ.get("OPENAI_API_BASE")
|
||||
|
||||
init_params["api_key"] = os.environ.get("OPENAI_API_KEY")
|
||||
|
||||
# A few sanity checks
|
||||
if api_type == "azure":
|
||||
if init_params.get("azure_endpoint") is None:
|
||||
raise ValueError(
|
||||
"OPENAI_API_BASE is not set in environment variables, this is required when api_type==azure"
|
||||
)
|
||||
if init_params.get("api_version") is None:
|
||||
raise ValueError(
|
||||
"OPENAI_API_VERSION is not set in environment variables, this is required when api_type==azure"
|
||||
)
|
||||
if init_params["api_key"].startswith("sk-"):
|
||||
raise ValueError(
|
||||
"OPENAI_API_KEY should not start with sk- when api_type==azure, "
|
||||
"are you using openai key by mistake?"
|
||||
)
|
||||
from openai import AzureOpenAI as Client
|
||||
else:
|
||||
from openai import OpenAI as Client
|
||||
self.client = Client(**init_params)
|
||||
|
||||
|
||||
class OAIChat(OAI):
|
||||
@retry_and_handle_exceptions(
|
||||
exception_to_check=(
|
||||
openai.RateLimitError,
|
||||
openai.APIStatusError,
|
||||
openai.APIConnectionError,
|
||||
KeyError,
|
||||
),
|
||||
max_retries=5,
|
||||
extract_delay_from_error_message=extract_delay_from_rate_limit_error_msg,
|
||||
)
|
||||
def generate(self, messages: list, **kwargs) -> List[float]:
|
||||
# chat api may return message with no content.
|
||||
message = self.client.chat.completions.create(
|
||||
model=os.environ.get("CHAT_MODEL_DEPLOYMENT_NAME"),
|
||||
messages=messages,
|
||||
**kwargs,
|
||||
).choices[0].message
|
||||
return getattr(message, "content", "")
|
||||
|
||||
@retry_and_handle_exceptions_for_generator(
|
||||
exception_to_check=(
|
||||
openai.RateLimitError,
|
||||
openai.APIStatusError,
|
||||
openai.APIConnectionError,
|
||||
KeyError,
|
||||
),
|
||||
max_retries=5,
|
||||
extract_delay_from_error_message=extract_delay_from_rate_limit_error_msg,
|
||||
)
|
||||
def stream(self, messages: list, **kwargs):
|
||||
response = self.client.chat.completions.create(
|
||||
model=os.environ.get("CHAT_MODEL_DEPLOYMENT_NAME"),
|
||||
messages=messages,
|
||||
stream=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
for chunk in response:
|
||||
if not chunk.choices:
|
||||
continue
|
||||
if chunk.choices[0].delta.content:
|
||||
yield chunk.choices[0].delta.content
|
||||
else:
|
||||
yield ""
|
||||
|
||||
|
||||
class OAIEmbedding(OAI):
|
||||
@retry_and_handle_exceptions(
|
||||
exception_to_check=openai.RateLimitError,
|
||||
max_retries=5,
|
||||
extract_delay_from_error_message=extract_delay_from_rate_limit_error_msg,
|
||||
)
|
||||
def generate(self, text: str) -> List[float]:
|
||||
return self.client.embeddings.create(
|
||||
input=text, model=os.environ.get("EMBEDDING_MODEL_DEPLOYMENT_NAME")
|
||||
).data[0].embedding
|
||||
|
||||
|
||||
def count_token(text: str) -> int:
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
return len(encoding.encode(text))
|
||||
|
||||
|
||||
def render_with_token_limit(template: Template, token_limit: int, **kwargs) -> str:
|
||||
text = template.render(**kwargs)
|
||||
token_count = count_token(text)
|
||||
if token_count > token_limit:
|
||||
message = f"token count {token_count} exceeds limit {token_limit}"
|
||||
log(message)
|
||||
raise ValueError(message)
|
||||
return text
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(count_token("hello world, this is impressive"))
|
||||
@@ -0,0 +1,92 @@
|
||||
from typing import Tuple, Union, Optional, Type
|
||||
import functools
|
||||
import time
|
||||
import random
|
||||
|
||||
|
||||
def retry_and_handle_exceptions(
|
||||
exception_to_check: Union[Type[Exception], Tuple[Type[Exception], ...]],
|
||||
max_retries: int = 3,
|
||||
initial_delay: float = 1,
|
||||
exponential_base: float = 2,
|
||||
jitter: bool = False,
|
||||
extract_delay_from_error_message: Optional[any] = None,
|
||||
):
|
||||
def deco_retry(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
delay = initial_delay
|
||||
for i in range(max_retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exception_to_check as e:
|
||||
if i == max_retries - 1:
|
||||
raise Exception(
|
||||
"Func execution failed after {0} retries: {1}".format(
|
||||
max_retries, e
|
||||
)
|
||||
)
|
||||
delay *= exponential_base * (1 + jitter * random.random())
|
||||
delay_from_error_message = None
|
||||
if extract_delay_from_error_message is not None:
|
||||
delay_from_error_message = extract_delay_from_error_message(
|
||||
str(e)
|
||||
)
|
||||
final_delay = (
|
||||
delay_from_error_message if delay_from_error_message else delay
|
||||
)
|
||||
print(
|
||||
"Func execution failed. Retrying in {0} seconds: {1}".format(
|
||||
final_delay, e
|
||||
)
|
||||
)
|
||||
time.sleep(final_delay)
|
||||
|
||||
return wrapper
|
||||
|
||||
return deco_retry
|
||||
|
||||
|
||||
def retry_and_handle_exceptions_for_generator(
|
||||
exception_to_check: Union[Type[Exception], Tuple[Type[Exception], ...]],
|
||||
max_retries: int = 3,
|
||||
initial_delay: float = 1,
|
||||
exponential_base: float = 2,
|
||||
jitter: bool = False,
|
||||
extract_delay_from_error_message: Optional[any] = None,
|
||||
):
|
||||
def deco_retry(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
delay = initial_delay
|
||||
for i in range(max_retries):
|
||||
try:
|
||||
for value in func(*args, **kwargs):
|
||||
yield value
|
||||
break
|
||||
except exception_to_check as e:
|
||||
if i == max_retries - 1:
|
||||
raise Exception(
|
||||
"Func execution failed after {0} retries: {1}".format(
|
||||
max_retries, e
|
||||
)
|
||||
)
|
||||
delay *= exponential_base * (1 + jitter * random.random())
|
||||
delay_from_error_message = None
|
||||
if extract_delay_from_error_message is not None:
|
||||
delay_from_error_message = extract_delay_from_error_message(
|
||||
str(e)
|
||||
)
|
||||
final_delay = (
|
||||
delay_from_error_message if delay_from_error_message else delay
|
||||
)
|
||||
print(
|
||||
"Func execution failed. Retrying in {0} seconds: {1}".format(
|
||||
final_delay, e
|
||||
)
|
||||
)
|
||||
time.sleep(final_delay)
|
||||
|
||||
return wrapper
|
||||
|
||||
return deco_retry
|
||||
@@ -0,0 +1,9 @@
|
||||
agent-framework>=1.0.1
|
||||
agent-framework-openai>=1.0.1
|
||||
python-dotenv
|
||||
PyPDF2
|
||||
faiss-cpu
|
||||
openai
|
||||
jinja2
|
||||
tiktoken
|
||||
requests
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Test script for the chat-with-pdf MAF workflow."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from workflow import PdfChatInput, create_workflow # noqa: E402
|
||||
|
||||
|
||||
async def test_single_turn():
|
||||
print("--- single turn ---")
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
PdfChatInput(
|
||||
question="What is BERT?",
|
||||
pdf_url="https://arxiv.org/pdf/1810.04805.pdf",
|
||||
)
|
||||
)
|
||||
output = result.get_outputs()[0]
|
||||
print(f"Answer: {output['answer']}")
|
||||
print(f"Context: {output['context']}")
|
||||
|
||||
|
||||
async def test_multi_turn():
|
||||
print("\n--- multi turn ---")
|
||||
history = [
|
||||
{
|
||||
"inputs": {"question": "What is BERT?"},
|
||||
"outputs": {"answer": "BERT is a language model by Google."},
|
||||
}
|
||||
]
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
PdfChatInput(
|
||||
question="How was it trained?",
|
||||
pdf_url="https://arxiv.org/pdf/1810.04805.pdf",
|
||||
chat_history=history,
|
||||
)
|
||||
)
|
||||
output = result.get_outputs()[0]
|
||||
print(f"Answer: {output['answer']}")
|
||||
print(f"Context: {output['context']}")
|
||||
|
||||
|
||||
async def main():
|
||||
await test_single_turn()
|
||||
await test_multi_turn()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,38 @@
|
||||
import asyncio
|
||||
|
||||
from workflow_multi_node import ChatInput, create_workflow
|
||||
|
||||
|
||||
async def main():
|
||||
# Single-turn test
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
ChatInput(
|
||||
question="what NLP tasks does it perform well?",
|
||||
pdf_url="https://arxiv.org/pdf/1810.04805.pdf",
|
||||
)
|
||||
)
|
||||
output = result.get_outputs()[0]
|
||||
print(f"Answer: {output['answer']}")
|
||||
print(f"Context: {output['context']}")
|
||||
|
||||
# Multi-turn test
|
||||
workflow2 = create_workflow()
|
||||
result2 = await workflow2.run(
|
||||
ChatInput(
|
||||
question="Can you elaborate on the fine-tuning approach?",
|
||||
pdf_url="https://arxiv.org/pdf/1810.04805.pdf",
|
||||
chat_history=[
|
||||
{
|
||||
"inputs": {"question": "what NLP tasks does it perform well?"},
|
||||
"outputs": {"answer": output["answer"]},
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
output2 = result2.get_outputs()[0]
|
||||
print(f"\nFollow-up Answer: {output2['answer']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,38 @@
|
||||
import asyncio
|
||||
|
||||
from workflow_single_node import ChatInput, create_workflow
|
||||
|
||||
|
||||
async def main():
|
||||
# Single-turn test
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
ChatInput(
|
||||
question="what NLP tasks does it perform well?",
|
||||
pdf_url="https://arxiv.org/pdf/1810.04805.pdf",
|
||||
)
|
||||
)
|
||||
output = result.get_outputs()[0]
|
||||
print(f"Answer: {output['answer']}")
|
||||
print(f"Context: {output['context']}")
|
||||
|
||||
# Multi-turn test
|
||||
workflow2 = create_workflow()
|
||||
result2 = await workflow2.run(
|
||||
ChatInput(
|
||||
question="Can you elaborate on the fine-tuning approach?",
|
||||
pdf_url="https://arxiv.org/pdf/1810.04805.pdf",
|
||||
chat_history=[
|
||||
{
|
||||
"inputs": {"question": "what NLP tasks does it perform well?"},
|
||||
"outputs": {"answer": output["answer"]},
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
output2 = result2.get_outputs()[0]
|
||||
print(f"\nFollow-up Answer: {output2['answer']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,163 @@
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
|
||||
from chat_with_pdf.build_index import create_faiss_index
|
||||
from chat_with_pdf.constants import PDF_DIR, INDEX_DIR
|
||||
from chat_with_pdf.download import download
|
||||
from chat_with_pdf.find_context import find_context
|
||||
from chat_with_pdf.qna import qna
|
||||
from chat_with_pdf.rewrite_question import rewrite_question
|
||||
from chat_with_pdf.utils.lock import acquire_lock
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PdfChatInput:
|
||||
question: str
|
||||
pdf_url: str = "https://arxiv.org/pdf/1810.04805.pdf"
|
||||
chat_history: list = field(default_factory=list)
|
||||
config: dict = field(default_factory=lambda: {
|
||||
"EMBEDDING_MODEL_DEPLOYMENT_NAME": os.environ.get("EMBEDDING_MODEL_DEPLOYMENT_NAME", "text-embedding-ada-002"),
|
||||
"CHAT_MODEL_DEPLOYMENT_NAME": os.environ.get("CHAT_MODEL_DEPLOYMENT_NAME", "gpt-4"),
|
||||
"PROMPT_TOKEN_LIMIT": os.environ.get("PROMPT_TOKEN_LIMIT", "3000"),
|
||||
"MAX_COMPLETION_TOKENS": os.environ.get("MAX_COMPLETION_TOKENS", "1024"),
|
||||
"VERBOSE": os.environ.get("VERBOSE", "true"),
|
||||
"CHUNK_SIZE": os.environ.get("CHUNK_SIZE", "1024"),
|
||||
"CHUNK_OVERLAP": os.environ.get("CHUNK_OVERLAP", "64"),
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
class BranchResult:
|
||||
index_path: str | None = None
|
||||
rewritten_question: str | None = None
|
||||
chat_history: list = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Executors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _to_chatml(history: list) -> list[dict]:
|
||||
messages = []
|
||||
for item in history:
|
||||
messages.append({"role": "user", "content": item["inputs"]["question"]})
|
||||
messages.append({"role": "assistant", "content": item["outputs"]["answer"]})
|
||||
return messages
|
||||
|
||||
|
||||
class InputExecutor(Executor):
|
||||
"""Sets environment variables from config and creates working directories."""
|
||||
|
||||
@handler
|
||||
async def receive(self, inp: PdfChatInput, ctx: WorkflowContext[PdfChatInput]) -> None:
|
||||
for key, value in inp.config.items():
|
||||
os.environ[key] = str(value)
|
||||
|
||||
base_dir = os.path.join(os.path.dirname(__file__), "chat_with_pdf")
|
||||
with acquire_lock(os.path.join(base_dir, "create_folder.lock")):
|
||||
os.makedirs(PDF_DIR, exist_ok=True)
|
||||
os.makedirs(INDEX_DIR, exist_ok=True)
|
||||
|
||||
await ctx.send_message(inp)
|
||||
|
||||
|
||||
class IndexExecutor(Executor):
|
||||
"""Downloads PDF and builds FAISS index (merges download_tool + build_index_tool)."""
|
||||
|
||||
@handler
|
||||
async def process(self, inp: PdfChatInput, ctx: WorkflowContext[BranchResult]) -> None:
|
||||
pdf_path = download(inp.pdf_url)
|
||||
index_path = create_faiss_index(pdf_path)
|
||||
await ctx.send_message(BranchResult(index_path=index_path))
|
||||
|
||||
|
||||
class RewriteExecutor(Executor):
|
||||
"""Rewrites question using chat history for better context retrieval."""
|
||||
|
||||
@handler
|
||||
async def process(self, inp: PdfChatInput, ctx: WorkflowContext[BranchResult]) -> None:
|
||||
rewritten = rewrite_question(inp.question, _to_chatml(inp.chat_history))
|
||||
await ctx.send_message(BranchResult(
|
||||
rewritten_question=rewritten,
|
||||
chat_history=inp.chat_history,
|
||||
))
|
||||
|
||||
|
||||
class ContextAndQnAExecutor(Executor):
|
||||
"""Fan-in: finds relevant context from index, then generates answer."""
|
||||
|
||||
@handler
|
||||
async def process(self, results: list[BranchResult], ctx: WorkflowContext[Never, dict]) -> None:
|
||||
index_path = None
|
||||
rewritten_question = None
|
||||
chat_history: list = []
|
||||
for r in results:
|
||||
if r.index_path:
|
||||
index_path = r.index_path
|
||||
if r.rewritten_question:
|
||||
rewritten_question = r.rewritten_question
|
||||
if r.chat_history:
|
||||
chat_history = r.chat_history
|
||||
|
||||
prompt, context = find_context(rewritten_question, index_path)
|
||||
|
||||
stream = qna(prompt, _to_chatml(chat_history))
|
||||
answer = "".join(stream)
|
||||
|
||||
await ctx.yield_output({
|
||||
"answer": answer,
|
||||
"context": [c.text for c in context],
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workflow: input → fan_out[index, rewrite] → fan_in → context_qna
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
_index = IndexExecutor(id="index")
|
||||
_rewrite = RewriteExecutor(id="rewrite")
|
||||
_context_qna = ContextAndQnAExecutor(id="context_qna")
|
||||
return (
|
||||
WorkflowBuilder(name="ChatWithPdfWorkflow", start_executor=_input)
|
||||
.add_fan_out_edges(_input, [_index, _rewrite])
|
||||
.add_fan_in_edges([_index, _rewrite], _context_qna)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
PdfChatInput(
|
||||
question="What is BERT?",
|
||||
pdf_url="https://arxiv.org/pdf/1810.04805.pdf",
|
||||
)
|
||||
)
|
||||
output = result.get_outputs()[0]
|
||||
print(f"Answer: {output['answer']}")
|
||||
print(f"Context: {output['context']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,139 @@
|
||||
"""MAF workflow converted from chat-with-pdf/flow.dag.yaml.multi-node
|
||||
|
||||
Graph:
|
||||
InputExecutor ──fan-out──> DownloadExecutor -> BuildIndexExecutor ──┐
|
||||
└──────> RewriteQuestionExecutor ─────────────────┘──fan-in──> QnaExecutor
|
||||
|
||||
InputExecutor: sets up directories, fans out ChatInput
|
||||
DownloadExecutor: downloads PDF from url
|
||||
BuildIndexExecutor: builds FAISS index from PDF
|
||||
RewriteQuestionExecutor: rewrites question using chat history
|
||||
QnaExecutor: finds context from index, runs QnA, yields output
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
|
||||
from chat_with_pdf.download import download
|
||||
from chat_with_pdf.build_index import create_faiss_index
|
||||
from chat_with_pdf.rewrite_question import rewrite_question
|
||||
from chat_with_pdf.find_context import find_context
|
||||
from chat_with_pdf.qna import qna
|
||||
from chat_with_pdf.constants import PDF_DIR, INDEX_DIR
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatInput:
|
||||
question: str
|
||||
pdf_url: str = "https://arxiv.org/pdf/1810.04805.pdf"
|
||||
chat_history: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QnaBranchResult:
|
||||
index_path: str = ""
|
||||
rewritten_question: str = ""
|
||||
chat_history: list = field(default_factory=list)
|
||||
|
||||
|
||||
def _convert_chat_history(history: list) -> list[dict]:
|
||||
messages = []
|
||||
for item in history:
|
||||
messages.append({"role": "user", "content": item["inputs"]["question"]})
|
||||
messages.append({"role": "assistant", "content": item["outputs"]["answer"]})
|
||||
return messages
|
||||
|
||||
|
||||
class InputExecutor(Executor):
|
||||
@handler
|
||||
async def receive(self, chat_input: ChatInput, ctx: WorkflowContext[ChatInput]) -> None:
|
||||
os.makedirs(PDF_DIR, exist_ok=True)
|
||||
os.makedirs(INDEX_DIR, exist_ok=True)
|
||||
await ctx.send_message(chat_input)
|
||||
|
||||
|
||||
class DownloadExecutor(Executor):
|
||||
@handler
|
||||
async def run(self, chat_input: ChatInput, ctx: WorkflowContext[str]) -> None:
|
||||
pdf_path = download(chat_input.pdf_url)
|
||||
await ctx.send_message(pdf_path)
|
||||
|
||||
|
||||
class BuildIndexExecutor(Executor):
|
||||
@handler
|
||||
async def run(self, pdf_path: str, ctx: WorkflowContext[QnaBranchResult]) -> None:
|
||||
index_path = create_faiss_index(pdf_path)
|
||||
await ctx.send_message(QnaBranchResult(index_path=index_path))
|
||||
|
||||
|
||||
class RewriteQuestionExecutor(Executor):
|
||||
@handler
|
||||
async def run(self, chat_input: ChatInput, ctx: WorkflowContext[QnaBranchResult]) -> None:
|
||||
rewritten = rewrite_question(chat_input.question, chat_input.chat_history)
|
||||
await ctx.send_message(QnaBranchResult(
|
||||
rewritten_question=rewritten,
|
||||
chat_history=chat_input.chat_history,
|
||||
))
|
||||
|
||||
|
||||
class QnaExecutor(Executor):
|
||||
@handler
|
||||
async def run(self, results: list[QnaBranchResult], ctx: WorkflowContext[Never, dict]) -> None:
|
||||
index_path = ""
|
||||
rewritten_question = ""
|
||||
chat_history = []
|
||||
for r in results:
|
||||
if r.index_path:
|
||||
index_path = r.index_path
|
||||
if r.rewritten_question:
|
||||
rewritten_question = r.rewritten_question
|
||||
if r.chat_history:
|
||||
chat_history = r.chat_history
|
||||
|
||||
prompt, context = find_context(rewritten_question, index_path)
|
||||
history_messages = _convert_chat_history(chat_history)
|
||||
stream = qna(prompt, history_messages)
|
||||
answer = "".join(stream)
|
||||
await ctx.yield_output({"answer": answer, "context": context})
|
||||
|
||||
|
||||
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")
|
||||
_download = DownloadExecutor(id="download")
|
||||
_build_index = BuildIndexExecutor(id="build_index")
|
||||
_rewrite = RewriteQuestionExecutor(id="rewrite_question")
|
||||
_qna = QnaExecutor(id="qna")
|
||||
return (
|
||||
WorkflowBuilder(name="ChatWithPdfMultiNode", start_executor=_input)
|
||||
.add_fan_out_edges(_input, [_download, _rewrite])
|
||||
.add_edge(_download, _build_index)
|
||||
.add_fan_in_edges([_build_index, _rewrite], _qna)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
ChatInput(question="what NLP tasks does it perform well?")
|
||||
)
|
||||
output = result.get_outputs()[0]
|
||||
print(f"Answer: {output['answer']}")
|
||||
print(f"Context: {output['context']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
"""MAF workflow converted from chat-with-pdf/flow.dag.yaml.single-node
|
||||
|
||||
Graph: SetupEnvExecutor -> ChatWithPdfExecutor
|
||||
|
||||
SetupEnvExecutor: creates required directories (replaces PF connection setup)
|
||||
ChatWithPdfExecutor: downloads PDF, builds index, rewrites question, finds context, runs QnA
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
|
||||
from chat_with_pdf.main import chat_with_pdf
|
||||
from chat_with_pdf.constants import PDF_DIR, INDEX_DIR
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatInput:
|
||||
question: str
|
||||
pdf_url: str = "https://arxiv.org/pdf/1810.04805.pdf"
|
||||
chat_history: list = field(default_factory=list)
|
||||
|
||||
|
||||
def _convert_chat_history(history: list) -> list[dict]:
|
||||
messages = []
|
||||
for item in history:
|
||||
messages.append({"role": "user", "content": item["inputs"]["question"]})
|
||||
messages.append({"role": "assistant", "content": item["outputs"]["answer"]})
|
||||
return messages
|
||||
|
||||
|
||||
class SetupEnvExecutor(Executor):
|
||||
@handler
|
||||
async def setup(self, chat_input: ChatInput, ctx: WorkflowContext[ChatInput]) -> None:
|
||||
os.makedirs(PDF_DIR, exist_ok=True)
|
||||
os.makedirs(INDEX_DIR, exist_ok=True)
|
||||
await ctx.send_message(chat_input)
|
||||
|
||||
|
||||
class ChatWithPdfExecutor(Executor):
|
||||
@handler
|
||||
async def run(self, chat_input: ChatInput, ctx: WorkflowContext[Never, dict]) -> None:
|
||||
history = _convert_chat_history(chat_input.chat_history)
|
||||
stream, context = chat_with_pdf(chat_input.question, chat_input.pdf_url, history)
|
||||
answer = "".join(stream)
|
||||
await ctx.yield_output({"answer": answer, "context": context})
|
||||
|
||||
|
||||
def create_workflow():
|
||||
"""Create a fresh workflow instance.
|
||||
|
||||
MAF workflows do not support concurrent execution, so each
|
||||
concurrent caller needs its own workflow instance.
|
||||
"""
|
||||
_setup = SetupEnvExecutor(id="setup_env")
|
||||
_chat = ChatWithPdfExecutor(id="chat_with_pdf")
|
||||
return (
|
||||
WorkflowBuilder(name="ChatWithPdfSingleNode", start_executor=_setup)
|
||||
.add_edge(_setup, _chat)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
workflow = create_workflow()
|
||||
result = await workflow.run(
|
||||
ChatInput(question="what NLP tasks does it perform well?")
|
||||
)
|
||||
output = result.get_outputs()[0]
|
||||
print(f"Answer: {output['answer']}")
|
||||
print(f"Context: {output['context']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,71 @@
|
||||
# Chat with PDF
|
||||
|
||||
This is a simple flow that allow you to ask questions about the content of a PDF file and get answers.
|
||||
You can run the flow with a URL to a PDF file and question as argument.
|
||||
Once it's launched it will download the PDF and build an index of the content.
|
||||
Then when you ask a question, it will look up the index to retrieve relevant content and post the question with the relevant content to OpenAI chat model (gpt-3.5-turbo or gpt4) to get an answer.
|
||||
|
||||
Learn more on corresponding [tutorials](../../../tutorials/e2e-development/chat-with-pdf.md).
|
||||
|
||||
Tools used in this flow:
|
||||
- custom `python` Tool
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install promptflow sdk and other dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Get started
|
||||
### Create connection in this folder
|
||||
|
||||
```bash
|
||||
# create connection needed by flow
|
||||
if pf connection list | grep open_ai_connection; then
|
||||
echo "open_ai_connection already exists"
|
||||
else
|
||||
pf connection create --file ../../../connections/azure_openai.yml --name open_ai_connection --set api_key=<your_api_key> api_base=<your_api_base>
|
||||
fi
|
||||
```
|
||||
|
||||
### CLI Example
|
||||
|
||||
#### Run flow
|
||||
|
||||
**Note**: this sample uses [predownloaded PDFs](./chat_with_pdf/.pdfs/) and [prebuilt FAISS Index](./chat_with_pdf/.index/) to speed up execution time.
|
||||
You can remove the folders to start a fresh run.
|
||||
|
||||
```bash
|
||||
# test with default input value in flow.dag.yaml
|
||||
pf flow test --flow .
|
||||
|
||||
# test with flow inputs
|
||||
pf flow test --flow . --inputs question="What is the name of the new language representation model introduced in the document?" pdf_url="https://arxiv.org/pdf/1810.04805.pdf"
|
||||
|
||||
# (Optional) create a random run name
|
||||
run_name="web_classification_"$(openssl rand -hex 12)
|
||||
|
||||
# run with multiline data, --name is optional
|
||||
pf run create --file batch_run.yaml --name $run_name
|
||||
|
||||
# visualize run output details
|
||||
pf run visualize --name $run_name
|
||||
```
|
||||
|
||||
#### Submit run to cloud
|
||||
|
||||
Assume we already have a connection named `open_ai_connection` in workspace.
|
||||
|
||||
```bash
|
||||
# set default workspace
|
||||
az account set -s <your_subscription_id>
|
||||
az configure --defaults group=<your_resource_group_name> workspace=<your_workspace_name>
|
||||
```
|
||||
|
||||
``` bash
|
||||
# create run
|
||||
pfazure run create --file batch_run.yaml --name $run_name
|
||||
```
|
||||
|
||||
Note: Click portal_url of the run to view the final snapshot.
|
||||
@@ -0,0 +1,6 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.append(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "chat_with_pdf")
|
||||
)
|
||||
|
After Width: | Height: | Size: 7.0 MiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 593 KiB |
@@ -0,0 +1,17 @@
|
||||
$schema: https://azuremlschemas.azureedge.net/promptflow/latest/Run.schema.json
|
||||
#name: chat_with_pdf_default_20230820_162219_559000
|
||||
flow: .
|
||||
data: ./data/bert-paper-qna.jsonl
|
||||
#run: <Uncomment to select a run input>
|
||||
column_mapping:
|
||||
chat_history: ${data.chat_history}
|
||||
pdf_url: ${data.pdf_url}
|
||||
question: ${data.question}
|
||||
config:
|
||||
EMBEDDING_MODEL_DEPLOYMENT_NAME: text-embedding-ada-002
|
||||
CHAT_MODEL_DEPLOYMENT_NAME: gpt-4
|
||||
PROMPT_TOKEN_LIMIT: 3000
|
||||
MAX_COMPLETION_TOKENS: 1024
|
||||
VERBOSE: true
|
||||
CHUNK_SIZE: 1024
|
||||
CHUNK_OVERLAP: 64
|
||||
@@ -0,0 +1,7 @@
|
||||
from promptflow.core import tool
|
||||
from chat_with_pdf.build_index import create_faiss_index
|
||||
|
||||
|
||||
@tool
|
||||
def build_index_tool(pdf_path: str) -> str:
|
||||
return create_faiss_index(pdf_path)
|
||||
@@ -0,0 +1,329 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Chat with PDF in Azure"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"\n",
|
||||
"This is a simple flow that allow you to ask questions about the content of a PDF file and get answers.\n",
|
||||
"You can run the flow with a URL to a PDF file and question as argument.\n",
|
||||
"Once it's launched it will download the PDF and build an index of the content. \n",
|
||||
"Then when you ask a question, it will look up the index to retrieve relevant content and post the question with the relevant content to OpenAI chat model (gpt-3.5-turbo or gpt4) to get an answer.\n",
|
||||
"\n",
|
||||
"## 0. Install dependencies"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"%pip install -r requirements.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 1. Connect to Azure Machine Learning Workspace"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from azure.identity import DefaultAzureCredential, InteractiveBrowserCredential\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" credential = DefaultAzureCredential()\n",
|
||||
" # Check if given credential can get token successfully.\n",
|
||||
" credential.get_token(\"https://management.azure.com/.default\")\n",
|
||||
"except Exception as ex:\n",
|
||||
" # Fall back to InteractiveBrowserCredential in case DefaultAzureCredential not work\n",
|
||||
" credential = InteractiveBrowserCredential()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1.1 Get familiar with the primary interface - PFClient"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import promptflow.azure as azure\n",
|
||||
"\n",
|
||||
"# Get a handle to workspace\n",
|
||||
"pf = azure.PFClient.from_config(credential=credential)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1.2 Create necessary connections\n",
|
||||
"\n",
|
||||
"Connection in prompt flow is for managing settings of your application behaviors incl. how to talk to different services (Azure OpenAI for example).\n",
|
||||
"\n",
|
||||
"Prepare your Azure OpenAI resource follow this [instruction](https://learn.microsoft.com/en-us/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal) and get your `api_key` if you don't have one.\n",
|
||||
"\n",
|
||||
"Please go to [workspace portal](https://ml.azure.com/), click `Prompt flow` -> `Connections` -> `Create`, then follow the instruction to create your own connections. \n",
|
||||
"Learn more on [connections](https://learn.microsoft.com/en-us/azure/machine-learning/prompt-flow/concept-connections?view=azureml-api-2)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"conn_name = \"open_ai_connection\"\n",
|
||||
"\n",
|
||||
"# TODO integrate with azure.ai sdk\n",
|
||||
"# currently we only support create connection in Azure ML Studio UI\n",
|
||||
"# raise Exception(f\"Please create {conn_name} connection in Azure ML Studio.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 2. Run a flow with setting (context size 2K)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"flow_path = \".\"\n",
|
||||
"data_path = \"./data/bert-paper-qna-3-line.jsonl\"\n",
|
||||
"\n",
|
||||
"config_2k_context = {\n",
|
||||
" \"EMBEDDING_MODEL_DEPLOYMENT_NAME\": \"text-embedding-ada-002\",\n",
|
||||
" \"CHAT_MODEL_DEPLOYMENT_NAME\": \"gpt-35-turbo\",\n",
|
||||
" \"PROMPT_TOKEN_LIMIT\": 2000,\n",
|
||||
" \"MAX_COMPLETION_TOKENS\": 256,\n",
|
||||
" \"VERBOSE\": True,\n",
|
||||
" \"CHUNK_SIZE\": 1024,\n",
|
||||
" \"CHUNK_OVERLAP\": 32,\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"column_mapping = {\n",
|
||||
" \"question\": \"${data.question}\",\n",
|
||||
" \"pdf_url\": \"${data.pdf_url}\",\n",
|
||||
" \"chat_history\": \"${data.chat_history}\",\n",
|
||||
" \"config\": config_2k_context,\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"run_2k_context = pf.run(\n",
|
||||
" flow=flow_path,\n",
|
||||
" data=data_path,\n",
|
||||
" column_mapping=column_mapping,\n",
|
||||
" display_name=\"chat_with_pdf_2k_context\",\n",
|
||||
" tags={\"chat_with_pdf\": \"\", \"1st_round\": \"\"},\n",
|
||||
")\n",
|
||||
"pf.stream(run_2k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"print(run_2k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"detail = pf.get_details(run_2k_context)\n",
|
||||
"\n",
|
||||
"detail"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 3. Evaluate the \"groundedness\"\n",
|
||||
"The `eval-groundedness flow` is using ChatGPT/GPT4 model to grade the answers generated by chat-with-pdf flow."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"eval_groundedness_flow_path = \"../../evaluation/eval-groundedness/\"\n",
|
||||
"eval_groundedness_2k_context = pf.run(\n",
|
||||
" flow=eval_groundedness_flow_path,\n",
|
||||
" run=run_2k_context,\n",
|
||||
" column_mapping={\n",
|
||||
" \"question\": \"${run.inputs.question}\",\n",
|
||||
" \"answer\": \"${run.outputs.answer}\",\n",
|
||||
" \"context\": \"${run.outputs.context}\",\n",
|
||||
" },\n",
|
||||
" display_name=\"eval_groundedness_2k_context\",\n",
|
||||
")\n",
|
||||
"pf.stream(eval_groundedness_2k_context)\n",
|
||||
"\n",
|
||||
"print(eval_groundedness_2k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 4. Try a different configuration and evaluate again - experimentation\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"flow_path = \".\"\n",
|
||||
"data_path = \"./data/bert-paper-qna-3-line.jsonl\"\n",
|
||||
"\n",
|
||||
"config_3k_context = {\n",
|
||||
" \"EMBEDDING_MODEL_DEPLOYMENT_NAME\": \"text-embedding-ada-002\",\n",
|
||||
" \"CHAT_MODEL_DEPLOYMENT_NAME\": \"gpt-35-turbo\",\n",
|
||||
" \"PROMPT_TOKEN_LIMIT\": 3000, # different from 2k context\n",
|
||||
" \"MAX_COMPLETION_TOKENS\": 256,\n",
|
||||
" \"VERBOSE\": True,\n",
|
||||
" \"CHUNK_SIZE\": 1024,\n",
|
||||
" \"CHUNK_OVERLAP\": 32,\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"column_mapping = {\n",
|
||||
" \"question\": \"${data.question}\",\n",
|
||||
" \"pdf_url\": \"${data.pdf_url}\",\n",
|
||||
" \"chat_history\": \"${data.chat_history}\",\n",
|
||||
" \"config\": config_3k_context,\n",
|
||||
"}\n",
|
||||
"run_3k_context = pf.run(\n",
|
||||
" flow=flow_path,\n",
|
||||
" data=data_path,\n",
|
||||
" column_mapping=column_mapping,\n",
|
||||
" display_name=\"chat_with_pdf_3k_context\",\n",
|
||||
" tags={\"chat_with_pdf\": \"\", \"2nd_round\": \"\"},\n",
|
||||
")\n",
|
||||
"pf.stream(run_3k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"print(run_3k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"detail = pf.get_details(run_3k_context)\n",
|
||||
"\n",
|
||||
"detail"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"eval_groundedness_3k_context = pf.run(\n",
|
||||
" flow=eval_groundedness_flow_path,\n",
|
||||
" run=run_3k_context,\n",
|
||||
" column_mapping={\n",
|
||||
" \"question\": \"${run.inputs.question}\",\n",
|
||||
" \"answer\": \"${run.outputs.answer}\",\n",
|
||||
" \"context\": \"${run.outputs.context}\",\n",
|
||||
" },\n",
|
||||
" display_name=\"eval_groundedness_3k_context\",\n",
|
||||
")\n",
|
||||
"pf.stream(eval_groundedness_3k_context)\n",
|
||||
"\n",
|
||||
"print(eval_groundedness_3k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"pf.get_details(eval_groundedness_3k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"pf.visualize([eval_groundedness_2k_context, eval_groundedness_3k_context])"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"build_doc": {
|
||||
"author": [
|
||||
"wangchao1230@github.com",
|
||||
"ttthree@github.com"
|
||||
],
|
||||
"category": "azure",
|
||||
"section": "Rag",
|
||||
"weight": 10
|
||||
},
|
||||
"description": "A tutorial of chat-with-pdf flow that executes in Azure AI",
|
||||
"kernelspec": {
|
||||
"display_name": "prompt-flow",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.9.17"
|
||||
},
|
||||
"stage": "development"
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Chat with PDF - test, evaluation and experimentation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"attachments": {},
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"\n",
|
||||
"We will walk you through how to use prompt flow Python SDK to test, evaluate and experiment with the \"Chat with PDF\" flow.\n",
|
||||
"\n",
|
||||
"## 0. Install dependencies"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"%pip install -r requirements.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"attachments": {},
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Create connections\n",
|
||||
"Connection in prompt flow is for managing settings of your application behaviors incl. how to talk to different services (Azure OpenAI for example)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import promptflow\n",
|
||||
"\n",
|
||||
"pf = promptflow.PFClient()\n",
|
||||
"\n",
|
||||
"# List all the available connections\n",
|
||||
"for c in pf.connections.list():\n",
|
||||
" print(c.name + \" (\" + c.type + \")\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"You will need to have a connection named \"open_ai_connection\" to run the chat_with_pdf flow."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# create needed connection\n",
|
||||
"from promptflow.entities import AzureOpenAIConnection, OpenAIConnection\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" conn_name = \"open_ai_connection\"\n",
|
||||
" conn = pf.connections.get(name=conn_name)\n",
|
||||
" print(\"using existing connection\")\n",
|
||||
"except:\n",
|
||||
" # Follow https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/create-resource?pivots=web-portal to create an Azure OpenAI resource.\n",
|
||||
" connection = AzureOpenAIConnection(\n",
|
||||
" name=conn_name,\n",
|
||||
" api_key=\"<user-input>\",\n",
|
||||
" api_base=\"<test_base>\",\n",
|
||||
" api_type=\"azure\",\n",
|
||||
" api_version=\"<test_version>\",\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" # use this if you have an existing OpenAI account\n",
|
||||
" # connection = OpenAIConnection(\n",
|
||||
" # name=conn_name,\n",
|
||||
" # api_key=\"<user-input>\",\n",
|
||||
" # )\n",
|
||||
" conn = pf.connections.create_or_update(connection)\n",
|
||||
" print(\"successfully created connection\")\n",
|
||||
"\n",
|
||||
"print(conn)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"attachments": {},
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Test the flow\n",
|
||||
"\n",
|
||||
"**Note**: this sample uses `predownloaded PDFs` and `prebuilt FAISS Index` to speed up execution time.\n",
|
||||
"You can remove the folders to start a fresh run."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ./chat_with_pdf/.pdfs/ stores predownloaded PDFs\n",
|
||||
"# ./chat_with_pdf/.index/ stores prebuilt index files\n",
|
||||
"\n",
|
||||
"output = pf.flows.test(\n",
|
||||
" \".\",\n",
|
||||
" inputs={\n",
|
||||
" \"chat_history\": [],\n",
|
||||
" \"pdf_url\": \"https://arxiv.org/pdf/1810.04805.pdf\",\n",
|
||||
" \"question\": \"what is BERT?\",\n",
|
||||
" },\n",
|
||||
")\n",
|
||||
"print(output)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"attachments": {},
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Run the flow with a data file"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"flow_path = \".\"\n",
|
||||
"data_path = \"./data/bert-paper-qna-3-line.jsonl\"\n",
|
||||
"\n",
|
||||
"config_2k_context = {\n",
|
||||
" \"EMBEDDING_MODEL_DEPLOYMENT_NAME\": \"text-embedding-ada-002\",\n",
|
||||
" \"CHAT_MODEL_DEPLOYMENT_NAME\": \"gpt-4\", # change this to the name of your deployment if you're using Azure OpenAI\n",
|
||||
" \"PROMPT_TOKEN_LIMIT\": 2000,\n",
|
||||
" \"MAX_COMPLETION_TOKENS\": 256,\n",
|
||||
" \"VERBOSE\": True,\n",
|
||||
" \"CHUNK_SIZE\": 1024,\n",
|
||||
" \"CHUNK_OVERLAP\": 64,\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"column_mapping = {\n",
|
||||
" \"question\": \"${data.question}\",\n",
|
||||
" \"pdf_url\": \"${data.pdf_url}\",\n",
|
||||
" \"chat_history\": \"${data.chat_history}\",\n",
|
||||
" \"config\": config_2k_context,\n",
|
||||
"}\n",
|
||||
"run_2k_context = pf.run(flow=flow_path, data=data_path, column_mapping=column_mapping)\n",
|
||||
"pf.stream(run_2k_context)\n",
|
||||
"\n",
|
||||
"print(run_2k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"pf.get_details(run_2k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Evaluate the \"groundedness\"\n",
|
||||
"The `eval-groundedness flow` is using ChatGPT/GPT4 model to grade the answers generated by chat-with-pdf flow."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"eval_groundedness_flow_path = \"../../evaluation/eval-groundedness/\"\n",
|
||||
"eval_groundedness_2k_context = pf.run(\n",
|
||||
" flow=eval_groundedness_flow_path,\n",
|
||||
" run=run_2k_context,\n",
|
||||
" column_mapping={\n",
|
||||
" \"question\": \"${run.inputs.question}\",\n",
|
||||
" \"answer\": \"${run.outputs.answer}\",\n",
|
||||
" \"context\": \"${run.outputs.context}\",\n",
|
||||
" },\n",
|
||||
" display_name=\"eval_groundedness_2k_context\",\n",
|
||||
")\n",
|
||||
"pf.stream(eval_groundedness_2k_context)\n",
|
||||
"\n",
|
||||
"print(eval_groundedness_2k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"pf.get_details(eval_groundedness_2k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"pf.get_metrics(eval_groundedness_2k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"pf.visualize(eval_groundedness_2k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"You will see a web page like this. It gives you detail about how each row is graded and even the details how the evaluation run executes:\n",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. Try a different configuration and evaluate again - experimentation\n",
|
||||
"\n",
|
||||
"NOTE: since we only use 3 lines of test data in this example, and because of the non-deterministic nature of LLMs, don't be surprised if you see exact same metrics when you run this process."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"config_3k_context = {\n",
|
||||
" \"EMBEDDING_MODEL_DEPLOYMENT_NAME\": \"text-embedding-ada-002\",\n",
|
||||
" \"CHAT_MODEL_DEPLOYMENT_NAME\": \"gpt-4\", # change this to the name of your deployment if you're using Azure OpenAI\n",
|
||||
" \"PROMPT_TOKEN_LIMIT\": 3000,\n",
|
||||
" \"MAX_COMPLETION_TOKENS\": 256,\n",
|
||||
" \"VERBOSE\": True,\n",
|
||||
" \"CHUNK_SIZE\": 1024,\n",
|
||||
" \"CHUNK_OVERLAP\": 64,\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"run_3k_context = pf.run(flow=flow_path, data=data_path, column_mapping=column_mapping)\n",
|
||||
"pf.stream(run_3k_context)\n",
|
||||
"\n",
|
||||
"print(run_3k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"eval_groundedness_3k_context = pf.run(\n",
|
||||
" flow=eval_groundedness_flow_path,\n",
|
||||
" run=run_3k_context,\n",
|
||||
" column_mapping={\n",
|
||||
" \"question\": \"${run.inputs.question}\",\n",
|
||||
" \"answer\": \"${run.outputs.answer}\",\n",
|
||||
" \"context\": \"${run.outputs.context}\",\n",
|
||||
" },\n",
|
||||
" display_name=\"eval_groundedness_3k_context\",\n",
|
||||
")\n",
|
||||
"pf.stream(eval_groundedness_3k_context)\n",
|
||||
"\n",
|
||||
"print(eval_groundedness_3k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"pf.get_details(eval_groundedness_3k_context)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"pf.visualize([eval_groundedness_2k_context, eval_groundedness_3k_context])"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"build_doc": {
|
||||
"author": [
|
||||
"wangchao1230@github.com",
|
||||
"ttthree@github.com"
|
||||
],
|
||||
"category": "local",
|
||||
"section": "Rag",
|
||||
"weight": 10
|
||||
},
|
||||
"description": "A tutorial of chat-with-pdf flow that allows user ask questions about the content of a PDF file and get answers",
|
||||
"kernelspec": {
|
||||
"display_name": "prompt-flow",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.9.19"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Azure OpenAI, uncomment below section if you want to use Azure OpenAI
|
||||
# Note: EMBEDDING_MODEL_DEPLOYMENT_NAME and CHAT_MODEL_DEPLOYMENT_NAME are deployment names for Azure OpenAI
|
||||
OPENAI_API_TYPE=azure
|
||||
OPENAI_API_BASE=<your_AOAI_endpoint>
|
||||
OPENAI_API_KEY=<your_AOAI_key>
|
||||
OPENAI_API_VERSION=2023-05-15
|
||||
EMBEDDING_MODEL_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
CHAT_MODEL_DEPLOYMENT_NAME=gpt-4
|
||||
|
||||
# OpenAI, uncomment below section if you want to use OpenAI
|
||||
# Note: EMBEDDING_MODEL_DEPLOYMENT_NAME and CHAT_MODEL_DEPLOYMENT_NAME are model names for OpenAI
|
||||
#OPENAI_API_KEY=<your_openai_key>
|
||||
#OPENAI_ORG_ID=<your_openai_org_id> # this is optional
|
||||
#EMBEDDING_MODEL_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
#CHAT_MODEL_DEPLOYMENT_NAME=gpt-4
|
||||
|
||||
PROMPT_TOKEN_LIMIT=2000
|
||||
MAX_COMPLETION_TOKENS=1024
|
||||
CHUNK_SIZE=256
|
||||
CHUNK_OVERLAP=16
|
||||
VERBOSE=True
|
||||
@@ -0,0 +1,27 @@
|
||||
# Chat with PDF
|
||||
This is a simple Python application that allow you to ask questions about the content of a PDF file and get answers.
|
||||
It's a console application that you start with a URL to a PDF file as argument. Once it's launched it will download the PDF and build an index of the content. Then when you ask a question, it will look up the index to retrieve relevant content and post the question with the relevant content to OpenAI chat model (gpt-3.5-turbo or gpt4) to get an answer.
|
||||
|
||||
## Screenshot - ask questions about BERT paper
|
||||

|
||||
|
||||
## How it works?
|
||||
|
||||
## Get started
|
||||
### Create .env file in this folder with below content
|
||||
```
|
||||
OPENAI_API_BASE=<AOAI_endpoint>
|
||||
OPENAI_API_KEY=<AOAI_key>
|
||||
EMBEDDING_MODEL_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
CHAT_MODEL_DEPLOYMENT_NAME=gpt-35-turbo
|
||||
PROMPT_TOKEN_LIMIT=3000
|
||||
MAX_COMPLETION_TOKENS=256
|
||||
VERBOSE=false
|
||||
CHUNK_SIZE=1024
|
||||
CHUNK_OVERLAP=64
|
||||
```
|
||||
Note: CHAT_MODEL_DEPLOYMENT_NAME should point to a chat model like gpt-3.5-turbo or gpt-4
|
||||
### Run the command line
|
||||
```shell
|
||||
python main.py <url-to-pdf-file>
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -0,0 +1,69 @@
|
||||
import PyPDF2
|
||||
import faiss
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from utils.oai import OAIEmbedding
|
||||
from utils.index import FAISSIndex
|
||||
from utils.logging import log
|
||||
from utils.lock import acquire_lock
|
||||
from constants import INDEX_DIR
|
||||
|
||||
|
||||
def create_faiss_index(pdf_path: str) -> str:
|
||||
chunk_size = int(os.environ.get("CHUNK_SIZE"))
|
||||
chunk_overlap = int(os.environ.get("CHUNK_OVERLAP"))
|
||||
log(f"Chunk size: {chunk_size}, chunk overlap: {chunk_overlap}")
|
||||
|
||||
file_name = Path(pdf_path).name + f".index_{chunk_size}_{chunk_overlap}"
|
||||
index_persistent_path = Path(INDEX_DIR) / file_name
|
||||
index_persistent_path = index_persistent_path.resolve().as_posix()
|
||||
lock_path = index_persistent_path + ".lock"
|
||||
log("Index path: " + os.path.abspath(index_persistent_path))
|
||||
|
||||
with acquire_lock(lock_path):
|
||||
if os.path.exists(os.path.join(index_persistent_path, "index.faiss")):
|
||||
log("Index already exists, bypassing index creation")
|
||||
return index_persistent_path
|
||||
else:
|
||||
if not os.path.exists(index_persistent_path):
|
||||
os.makedirs(index_persistent_path)
|
||||
|
||||
log("Building index")
|
||||
pdf_reader = PyPDF2.PdfReader(pdf_path)
|
||||
|
||||
text = ""
|
||||
for page in pdf_reader.pages:
|
||||
text += page.extract_text()
|
||||
|
||||
# Chunk the words into segments of X words with Y-word overlap, X=CHUNK_SIZE, Y=OVERLAP_SIZE
|
||||
segments = split_text(text, chunk_size, chunk_overlap)
|
||||
|
||||
log(f"Number of segments: {len(segments)}")
|
||||
|
||||
index = FAISSIndex(index=faiss.IndexFlatL2(1536), embedding=OAIEmbedding())
|
||||
index.insert_batch(segments)
|
||||
|
||||
index.save(index_persistent_path)
|
||||
|
||||
log("Index built: " + index_persistent_path)
|
||||
return index_persistent_path
|
||||
|
||||
|
||||
# Split the text into chunks with CHUNK_SIZE and CHUNK_OVERLAP as character count
|
||||
def split_text(text, chunk_size, chunk_overlap):
|
||||
# Calculate the number of chunks
|
||||
num_chunks = (len(text) - chunk_overlap) // (chunk_size - chunk_overlap)
|
||||
|
||||
# Split the text into chunks
|
||||
chunks = []
|
||||
for i in range(num_chunks):
|
||||
start = i * (chunk_size - chunk_overlap)
|
||||
end = start + chunk_size
|
||||
chunks.append(text[start:end])
|
||||
|
||||
# Add the last chunk
|
||||
chunks.append(text[num_chunks * (chunk_size - chunk_overlap):])
|
||||
|
||||
return chunks
|
||||
@@ -0,0 +1,5 @@
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PDF_DIR = os.path.join(BASE_DIR, ".pdfs")
|
||||
INDEX_DIR = os.path.join(BASE_DIR, ".index/.pdfs/")
|
||||
@@ -0,0 +1,31 @@
|
||||
import requests
|
||||
import os
|
||||
import re
|
||||
|
||||
from utils.lock import acquire_lock
|
||||
from utils.logging import log
|
||||
from constants import PDF_DIR
|
||||
|
||||
|
||||
# Download a pdf file from a url and return the path to the file
|
||||
def download(url: str) -> str:
|
||||
path = os.path.join(PDF_DIR, normalize_filename(url) + ".pdf")
|
||||
lock_path = path + ".lock"
|
||||
|
||||
with acquire_lock(lock_path):
|
||||
if os.path.exists(path):
|
||||
log("Pdf already exists in " + os.path.abspath(path))
|
||||
return path
|
||||
|
||||
log("Downloading pdf from " + url)
|
||||
response = requests.get(url)
|
||||
|
||||
with open(path, "wb") as f:
|
||||
f.write(response.content)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def normalize_filename(filename):
|
||||
# Replace any invalid characters with an underscore
|
||||
return re.sub(r"[^\w\-_. ]", "_", filename)
|
||||
@@ -0,0 +1,31 @@
|
||||
import faiss
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
import os
|
||||
|
||||
from utils.index import FAISSIndex
|
||||
from utils.oai import OAIEmbedding, render_with_token_limit
|
||||
from utils.logging import log
|
||||
|
||||
|
||||
def find_context(question: str, index_path: str):
|
||||
index = FAISSIndex(index=faiss.IndexFlatL2(1536), embedding=OAIEmbedding())
|
||||
index.load(path=index_path)
|
||||
snippets = index.query(question, top_k=5)
|
||||
|
||||
template = Environment(
|
||||
loader=FileSystemLoader(os.path.dirname(os.path.abspath(__file__)))
|
||||
).get_template("qna_prompt.md")
|
||||
token_limit = int(os.environ.get("PROMPT_TOKEN_LIMIT"))
|
||||
|
||||
# Try to render the template with token limit and reduce snippet count if it fails
|
||||
while True:
|
||||
try:
|
||||
prompt = render_with_token_limit(
|
||||
template, token_limit, question=question, context=enumerate(snippets)
|
||||
)
|
||||
break
|
||||
except ValueError:
|
||||
snippets = snippets[:-1]
|
||||
log(f"Reducing snippet count to {len(snippets)} to fit token limit")
|
||||
|
||||
return prompt, snippets
|
||||
@@ -0,0 +1,68 @@
|
||||
import argparse
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
from qna import qna
|
||||
from find_context import find_context
|
||||
from rewrite_question import rewrite_question
|
||||
from build_index import create_faiss_index
|
||||
from download import download
|
||||
from utils.lock import acquire_lock
|
||||
from constants import PDF_DIR, INDEX_DIR
|
||||
|
||||
|
||||
def chat_with_pdf(question: str, pdf_url: str, history: list):
|
||||
with acquire_lock("create_folder.lock"):
|
||||
if not os.path.exists(PDF_DIR):
|
||||
os.mkdir(PDF_DIR)
|
||||
if not os.path.exists(INDEX_DIR):
|
||||
os.makedirs(INDEX_DIR)
|
||||
|
||||
pdf_path = download(pdf_url)
|
||||
index_path = create_faiss_index(pdf_path)
|
||||
q = rewrite_question(question, history)
|
||||
prompt, context = find_context(q, index_path)
|
||||
stream = qna(prompt, history)
|
||||
|
||||
return stream, context
|
||||
|
||||
|
||||
def print_stream_and_return_full_answer(stream):
|
||||
answer = ""
|
||||
for str in stream:
|
||||
print(str, end="", flush=True)
|
||||
answer = answer + str + ""
|
||||
print(flush=True)
|
||||
|
||||
return answer
|
||||
|
||||
|
||||
def main_loop(url: str):
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), ".env"), override=True)
|
||||
|
||||
history = []
|
||||
while True:
|
||||
question = input("\033[92m" + "$User (type q! to quit): " + "\033[0m")
|
||||
if question == "q!":
|
||||
break
|
||||
|
||||
stream, context = chat_with_pdf(question, url, history)
|
||||
|
||||
print("\033[92m" + "$Bot: " + "\033[0m", end=" ", flush=True)
|
||||
answer = print_stream_and_return_full_answer(stream)
|
||||
history = history + [
|
||||
{"role": "user", "content": question},
|
||||
{"role": "assistant", "content": answer},
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Ask questions about a PDF file")
|
||||
parser.add_argument("url", help="URL to the PDF file")
|
||||
args = parser.parse_args()
|
||||
|
||||
main_loop(args.url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
from utils.oai import OAIChat
|
||||
|
||||
|
||||
def qna(prompt: str, history: list):
|
||||
max_completion_tokens = int(os.environ.get("MAX_COMPLETION_TOKENS"))
|
||||
|
||||
chat = OAIChat()
|
||||
stream = chat.stream(
|
||||
messages=history + [{"role": "user", "content": prompt}],
|
||||
max_tokens=max_completion_tokens,
|
||||
)
|
||||
|
||||
return stream
|
||||
@@ -0,0 +1,15 @@
|
||||
You're a smart assistant can answer questions based on provided context and previous conversation history between you and human.
|
||||
|
||||
Use the context to answer the question at the end, note that the context has order and importance - e.g. context #1 is more important than #2.
|
||||
|
||||
Try as much as you can to answer based on the provided the context, if you cannot derive the answer from the context, you should say you don't know.
|
||||
Answer in the same language as the question.
|
||||
|
||||
# Context
|
||||
{% for i, c in context %}
|
||||
## Context #{{i+1}}
|
||||
{{c.text}}
|
||||
{% endfor %}
|
||||
|
||||
# Question
|
||||
{{question}}
|
||||
@@ -0,0 +1,31 @@
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
import os
|
||||
from utils.logging import log
|
||||
from utils.oai import OAIChat, render_with_token_limit
|
||||
|
||||
|
||||
def rewrite_question(question: str, history: list):
|
||||
template = Environment(
|
||||
loader=FileSystemLoader(os.path.dirname(os.path.abspath(__file__)))
|
||||
).get_template("rewrite_question_prompt.md")
|
||||
token_limit = int(os.environ["PROMPT_TOKEN_LIMIT"])
|
||||
max_completion_tokens = int(os.environ["MAX_COMPLETION_TOKENS"])
|
||||
|
||||
# Try to render the prompt with token limit and reduce the history count if it fails
|
||||
while True:
|
||||
try:
|
||||
prompt = render_with_token_limit(
|
||||
template, token_limit, question=question, history=history
|
||||
)
|
||||
break
|
||||
except ValueError:
|
||||
history = history[:-1]
|
||||
log(f"Reducing chat history count to {len(history)} to fit token limit")
|
||||
|
||||
chat = OAIChat()
|
||||
rewritten_question = chat.generate(
|
||||
messages=[{"role": "user", "content": prompt}], max_tokens=max_completion_tokens
|
||||
)
|
||||
log(f"Rewritten question: {rewritten_question}")
|
||||
|
||||
return rewritten_question
|
||||
@@ -0,0 +1,33 @@
|
||||
You are able to reason from previous conversation and the recent question, to come up with a rewrite of the question which is concise but with enough information that people without knowledge of previous conversation can understand the question.
|
||||
|
||||
A few examples:
|
||||
|
||||
# Example 1
|
||||
## Previous conversation
|
||||
user: Who is Bill Clinton?
|
||||
assistant: Bill Clinton is an American politician who served as the 42nd President of the United States from 1993 to 2001.
|
||||
## Question
|
||||
user: When was he born?
|
||||
## Rewritten question
|
||||
When was Bill Clinton born?
|
||||
|
||||
# Example 2
|
||||
## Previous conversation
|
||||
user: What is BERT?
|
||||
assistant: BERT stands for "Bidirectional Encoder Representations from Transformers." It is a natural language processing (NLP) model developed by Google.
|
||||
user: What data was used for its training?
|
||||
assistant: The BERT (Bidirectional Encoder Representations from Transformers) model was trained on a large corpus of publicly available text from the internet. It was trained on a combination of books, articles, websites, and other sources to learn the language patterns and relationships between words.
|
||||
## Question
|
||||
user: What NLP tasks can it perform well?
|
||||
## Rewritten question
|
||||
What NLP tasks can BERT perform well?
|
||||
|
||||
Now comes the actual work - please respond with the rewritten question in the same language as the question, nothing else.
|
||||
|
||||
## Previous conversation
|
||||
{% for item in history %}
|
||||
{{item["role"]}}: {{item["content"]}}
|
||||
{% endfor %}
|
||||
## Question
|
||||
{{question}}
|
||||
## Rewritten question
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from main import chat_with_pdf, print_stream_and_return_full_answer\n",
|
||||
"from dotenv import load_dotenv\n",
|
||||
"\n",
|
||||
"load_dotenv()\n",
|
||||
"\n",
|
||||
"bert_paper_url = \"https://arxiv.org/pdf/1810.04805.pdf\"\n",
|
||||
"questions = [\n",
|
||||
" \"what is BERT?\",\n",
|
||||
" \"what NLP tasks does it perform well?\",\n",
|
||||
" \"is BERT suitable for NER?\",\n",
|
||||
" \"is it better than GPT\",\n",
|
||||
" \"when was GPT come up?\",\n",
|
||||
" \"when was BERT come up?\",\n",
|
||||
" \"so about same time?\",\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"history = []\n",
|
||||
"for q in questions:\n",
|
||||
" stream, context = chat_with_pdf(q, bert_paper_url, history)\n",
|
||||
" print(\"User: \" + q, flush=True)\n",
|
||||
" print(\"Bot: \", end=\"\", flush=True)\n",
|
||||
" answer = print_stream_and_return_full_answer(stream)\n",
|
||||
" history = history + [\n",
|
||||
" {\"role\": \"user\", \"content\": q},\n",
|
||||
" {\"role\": \"assistant\", \"content\": answer},\n",
|
||||
" ]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "pf",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.9.17"
|
||||
},
|
||||
"stage": "development"
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore
|
||||
@@ -0,0 +1,73 @@
|
||||
import os
|
||||
from typing import Iterable, List, Optional
|
||||
from dataclasses import dataclass
|
||||
from faiss import Index
|
||||
import faiss
|
||||
import pickle
|
||||
import numpy as np
|
||||
|
||||
from .oai import OAIEmbedding as Embedding
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResultEntity:
|
||||
text: str = None
|
||||
vector: List[float] = None
|
||||
score: float = None
|
||||
original_entity: dict = None
|
||||
metadata: dict = None
|
||||
|
||||
|
||||
INDEX_FILE_NAME = "index.faiss"
|
||||
DATA_FILE_NAME = "index.pkl"
|
||||
|
||||
|
||||
class FAISSIndex:
|
||||
def __init__(self, index: Index, embedding: Embedding) -> None:
|
||||
self.index = index
|
||||
self.docs = {} # id -> doc, doc is (text, metadata)
|
||||
self.embedding = embedding
|
||||
|
||||
def insert_batch(
|
||||
self, texts: Iterable[str], metadatas: Optional[List[dict]] = None
|
||||
) -> None:
|
||||
documents = []
|
||||
vectors = []
|
||||
for i, text in enumerate(texts):
|
||||
metadata = metadatas[i] if metadatas else {}
|
||||
vector = self.embedding.generate(text)
|
||||
documents.append((text, metadata))
|
||||
vectors.append(vector)
|
||||
|
||||
self.index.add(np.array(vectors, dtype=np.float32))
|
||||
self.docs.update(
|
||||
{i: doc for i, doc in enumerate(documents, start=len(self.docs))}
|
||||
)
|
||||
|
||||
pass
|
||||
|
||||
def query(self, text: str, top_k: int = 10) -> List[SearchResultEntity]:
|
||||
vector = self.embedding.generate(text)
|
||||
scores, indices = self.index.search(np.array([vector], dtype=np.float32), top_k)
|
||||
docs = []
|
||||
for j, i in enumerate(indices[0]):
|
||||
if i == -1: # This happens when not enough docs are returned.
|
||||
continue
|
||||
doc = self.docs[i]
|
||||
docs.append(
|
||||
SearchResultEntity(text=doc[0], metadata=doc[1], score=scores[0][j])
|
||||
)
|
||||
return docs
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
faiss.write_index(self.index, os.path.join(path, INDEX_FILE_NAME))
|
||||
# dump docs to pickle file
|
||||
with open(os.path.join(path, DATA_FILE_NAME), "wb") as f:
|
||||
pickle.dump(self.docs, f)
|
||||
pass
|
||||
|
||||
def load(self, path: str) -> None:
|
||||
self.index = faiss.read_index(os.path.join(path, INDEX_FILE_NAME))
|
||||
with open(os.path.join(path, DATA_FILE_NAME), "rb") as f:
|
||||
self.docs = pickle.load(f)
|
||||
pass
|
||||
@@ -0,0 +1,27 @@
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
import msvcrt
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def acquire_lock(filename):
|
||||
if not sys.platform.startswith("win"):
|
||||
with open(filename, "a+") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
yield f
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
else: # Windows
|
||||
with open(filename, "w") as f:
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
|
||||
yield f
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
|
||||
try:
|
||||
os.remove(filename)
|
||||
except OSError:
|
||||
pass # best effort to remove the lock file
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
|
||||
def log(message: str):
|
||||
verbose = os.environ.get("VERBOSE", "false")
|
||||
if verbose.lower() == "true":
|
||||
print(message, flush=True)
|
||||
@@ -0,0 +1,146 @@
|
||||
from typing import List
|
||||
import openai
|
||||
from openai.version import VERSION as OPENAI_VERSION
|
||||
import os
|
||||
import tiktoken
|
||||
from jinja2 import Template
|
||||
|
||||
from .retry import (
|
||||
retry_and_handle_exceptions,
|
||||
retry_and_handle_exceptions_for_generator,
|
||||
)
|
||||
from .logging import log
|
||||
|
||||
|
||||
def extract_delay_from_rate_limit_error_msg(text):
|
||||
import re
|
||||
|
||||
pattern = r"retry after (\d+)"
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
retry_time_from_message = match.group(1)
|
||||
return float(retry_time_from_message)
|
||||
else:
|
||||
return 5 # default retry time
|
||||
|
||||
|
||||
class OAI:
|
||||
def __init__(self):
|
||||
if OPENAI_VERSION.startswith("0."):
|
||||
raise Exception(
|
||||
"Please upgrade your OpenAI package to version >= 1.0.0 or "
|
||||
"using the command: pip install --upgrade openai."
|
||||
)
|
||||
init_params = {}
|
||||
api_type = os.environ.get("OPENAI_API_TYPE")
|
||||
if os.getenv("OPENAI_API_VERSION") is not None:
|
||||
init_params["api_version"] = os.environ.get("OPENAI_API_VERSION")
|
||||
if os.getenv("OPENAI_ORG_ID") is not None:
|
||||
init_params["organization"] = os.environ.get("OPENAI_ORG_ID")
|
||||
if os.getenv("OPENAI_API_KEY") is None:
|
||||
raise ValueError("OPENAI_API_KEY is not set in environment variables")
|
||||
if os.getenv("OPENAI_API_BASE") is not None:
|
||||
if api_type == "azure":
|
||||
init_params["azure_endpoint"] = os.environ.get("OPENAI_API_BASE")
|
||||
else:
|
||||
init_params["base_url"] = os.environ.get("OPENAI_API_BASE")
|
||||
|
||||
init_params["api_key"] = os.environ.get("OPENAI_API_KEY")
|
||||
|
||||
# A few sanity checks
|
||||
if api_type == "azure":
|
||||
if init_params.get("azure_endpoint") is None:
|
||||
raise ValueError(
|
||||
"OPENAI_API_BASE is not set in environment variables, this is required when api_type==azure"
|
||||
)
|
||||
if init_params.get("api_version") is None:
|
||||
raise ValueError(
|
||||
"OPENAI_API_VERSION is not set in environment variables, this is required when api_type==azure"
|
||||
)
|
||||
if init_params["api_key"].startswith("sk-"):
|
||||
raise ValueError(
|
||||
"OPENAI_API_KEY should not start with sk- when api_type==azure, "
|
||||
"are you using openai key by mistake?"
|
||||
)
|
||||
from openai import AzureOpenAI as Client
|
||||
else:
|
||||
from openai import OpenAI as Client
|
||||
self.client = Client(**init_params)
|
||||
|
||||
|
||||
class OAIChat(OAI):
|
||||
@retry_and_handle_exceptions(
|
||||
exception_to_check=(
|
||||
openai.RateLimitError,
|
||||
openai.APIStatusError,
|
||||
openai.APIConnectionError,
|
||||
KeyError,
|
||||
),
|
||||
max_retries=5,
|
||||
extract_delay_from_error_message=extract_delay_from_rate_limit_error_msg,
|
||||
)
|
||||
def generate(self, messages: list, **kwargs) -> List[float]:
|
||||
# chat api may return message with no content.
|
||||
message = self.client.chat.completions.create(
|
||||
model=os.environ.get("CHAT_MODEL_DEPLOYMENT_NAME"),
|
||||
messages=messages,
|
||||
**kwargs,
|
||||
).choices[0].message
|
||||
return getattr(message, "content", "")
|
||||
|
||||
@retry_and_handle_exceptions_for_generator(
|
||||
exception_to_check=(
|
||||
openai.RateLimitError,
|
||||
openai.APIStatusError,
|
||||
openai.APIConnectionError,
|
||||
KeyError,
|
||||
),
|
||||
max_retries=5,
|
||||
extract_delay_from_error_message=extract_delay_from_rate_limit_error_msg,
|
||||
)
|
||||
def stream(self, messages: list, **kwargs):
|
||||
response = self.client.chat.completions.create(
|
||||
model=os.environ.get("CHAT_MODEL_DEPLOYMENT_NAME"),
|
||||
messages=messages,
|
||||
stream=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
for chunk in response:
|
||||
if not chunk.choices:
|
||||
continue
|
||||
if chunk.choices[0].delta.content:
|
||||
yield chunk.choices[0].delta.content
|
||||
else:
|
||||
yield ""
|
||||
|
||||
|
||||
class OAIEmbedding(OAI):
|
||||
@retry_and_handle_exceptions(
|
||||
exception_to_check=openai.RateLimitError,
|
||||
max_retries=5,
|
||||
extract_delay_from_error_message=extract_delay_from_rate_limit_error_msg,
|
||||
)
|
||||
def generate(self, text: str) -> List[float]:
|
||||
return self.client.embeddings.create(
|
||||
input=text, model=os.environ.get("EMBEDDING_MODEL_DEPLOYMENT_NAME")
|
||||
).data[0].embedding
|
||||
|
||||
|
||||
def count_token(text: str) -> int:
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
return len(encoding.encode(text))
|
||||
|
||||
|
||||
def render_with_token_limit(template: Template, token_limit: int, **kwargs) -> str:
|
||||
text = template.render(**kwargs)
|
||||
token_count = count_token(text)
|
||||
if token_count > token_limit:
|
||||
message = f"token count {token_count} exceeds limit {token_limit}"
|
||||
log(message)
|
||||
raise ValueError(message)
|
||||
return text
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(count_token("hello world, this is impressive"))
|
||||
@@ -0,0 +1,92 @@
|
||||
from typing import Tuple, Union, Optional, Type
|
||||
import functools
|
||||
import time
|
||||
import random
|
||||
|
||||
|
||||
def retry_and_handle_exceptions(
|
||||
exception_to_check: Union[Type[Exception], Tuple[Type[Exception], ...]],
|
||||
max_retries: int = 3,
|
||||
initial_delay: float = 1,
|
||||
exponential_base: float = 2,
|
||||
jitter: bool = False,
|
||||
extract_delay_from_error_message: Optional[any] = None,
|
||||
):
|
||||
def deco_retry(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
delay = initial_delay
|
||||
for i in range(max_retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exception_to_check as e:
|
||||
if i == max_retries - 1:
|
||||
raise Exception(
|
||||
"Func execution failed after {0} retries: {1}".format(
|
||||
max_retries, e
|
||||
)
|
||||
)
|
||||
delay *= exponential_base * (1 + jitter * random.random())
|
||||
delay_from_error_message = None
|
||||
if extract_delay_from_error_message is not None:
|
||||
delay_from_error_message = extract_delay_from_error_message(
|
||||
str(e)
|
||||
)
|
||||
final_delay = (
|
||||
delay_from_error_message if delay_from_error_message else delay
|
||||
)
|
||||
print(
|
||||
"Func execution failed. Retrying in {0} seconds: {1}".format(
|
||||
final_delay, e
|
||||
)
|
||||
)
|
||||
time.sleep(final_delay)
|
||||
|
||||
return wrapper
|
||||
|
||||
return deco_retry
|
||||
|
||||
|
||||
def retry_and_handle_exceptions_for_generator(
|
||||
exception_to_check: Union[Type[Exception], Tuple[Type[Exception], ...]],
|
||||
max_retries: int = 3,
|
||||
initial_delay: float = 1,
|
||||
exponential_base: float = 2,
|
||||
jitter: bool = False,
|
||||
extract_delay_from_error_message: Optional[any] = None,
|
||||
):
|
||||
def deco_retry(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
delay = initial_delay
|
||||
for i in range(max_retries):
|
||||
try:
|
||||
for value in func(*args, **kwargs):
|
||||
yield value
|
||||
break
|
||||
except exception_to_check as e:
|
||||
if i == max_retries - 1:
|
||||
raise Exception(
|
||||
"Func execution failed after {0} retries: {1}".format(
|
||||
max_retries, e
|
||||
)
|
||||
)
|
||||
delay *= exponential_base * (1 + jitter * random.random())
|
||||
delay_from_error_message = None
|
||||
if extract_delay_from_error_message is not None:
|
||||
delay_from_error_message = extract_delay_from_error_message(
|
||||
str(e)
|
||||
)
|
||||
final_delay = (
|
||||
delay_from_error_message if delay_from_error_message else delay
|
||||
)
|
||||
print(
|
||||
"Func execution failed. Retrying in {0} seconds: {1}".format(
|
||||
final_delay, e
|
||||
)
|
||||
)
|
||||
time.sleep(final_delay)
|
||||
|
||||
return wrapper
|
||||
|
||||
return deco_retry
|
||||
@@ -0,0 +1,37 @@
|
||||
from promptflow.core import tool
|
||||
from chat_with_pdf.main import chat_with_pdf
|
||||
|
||||
|
||||
@tool
|
||||
def chat_with_pdf_tool(question: str, pdf_url: str, history: list, ready: str):
|
||||
history = convert_chat_history_to_chatml_messages(history)
|
||||
|
||||
stream, context = chat_with_pdf(question, pdf_url, history)
|
||||
|
||||
answer = ""
|
||||
for str in stream:
|
||||
answer = answer + str + ""
|
||||
|
||||
return {"answer": answer, "context": context}
|
||||
|
||||
|
||||
def convert_chat_history_to_chatml_messages(history):
|
||||
messages = []
|
||||
for item in history:
|
||||
messages.append({"role": "user", "content": item["inputs"]["question"]})
|
||||
messages.append({"role": "assistant", "content": item["outputs"]["answer"]})
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def convert_chatml_messages_to_chat_history(messages):
|
||||
history = []
|
||||
for i in range(0, len(messages), 2):
|
||||
history.append(
|
||||
{
|
||||
"inputs": {"question": messages[i]["content"]},
|
||||
"outputs": {"answer": messages[i + 1]["content"]},
|
||||
}
|
||||
)
|
||||
|
||||
return history
|
||||
@@ -0,0 +1 @@
|
||||
{"pdf_url":"https://arxiv.org/pdf/1810.04805.pdf", "chat_history":[], "question": "What is the name of the new language representation model introduced in the document?", "answer": "BERT", "context": "We introduce a new language representation model called BERT, which stands for Bidirectional Encoder Representations from Transformers."}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"pdf_url":"https://arxiv.org/pdf/1810.04805.pdf", "chat_history":[], "question": "What is the main difference between BERT and previous language representation models?", "answer": "BERT is designed to pretrain deep bidirectional representations from unlabeled text by jointly conditioning on both left and right context in all layers.", "context": "Unlike recent language representation models (Peters et al., 2018a; Radford et al., 2018), BERT is designed to pretrain deep bidirectional representations from unlabeled text by jointly conditioning on both left and right context in all layers."}
|
||||
{"pdf_url":"https://arxiv.org/pdf/1810.04805.pdf", "chat_history":[], "question": "What is the size of the vocabulary used by BERT?", "answer": "30,000", "context": "We use WordPiece embeddings (Wu et al., 2016) with a 30,000 token vocabulary."}
|
||||
{"pdf_url":"https://grs.pku.edu.cn/docs/2018-03/20180301083100898652.pdf", "chat_history":[], "question": "论文写作中论文引言有什么注意事项?", "answer":"", "context":""}
|
||||