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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:52 +08:00
commit e768098d0e
4004 changed files with 2804145 additions and 0 deletions
@@ -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())
+70
View File
@@ -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
![screenshot-chat-with-pdf](../assets/chat_with_pdf_console.png)
## 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")
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

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",
"![pf-visualize-screenshot](./media/chat-with-pdf/pf-visualize-screenshot.png)"
]
},
{
"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
![screenshot-chat-with-pdf](../assets/chat_with_pdf_console.png)
## 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":""}

Some files were not shown because too many files have changed in this diff Show More