mirror of
https://github.com/run-llama/product-specs-comparison.git
synced 2026-07-01 21:24:01 -04:00
first commit
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -0,0 +1,41 @@
|
||||
# Product Specs Comparison
|
||||
|
||||
A demo for LlamaIndex x PostHog observability.
|
||||
|
||||
## Set Up and Run
|
||||
|
||||
Clone the GitHub repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/run-llama/product-specs-comparison
|
||||
cd product-specs-comparison
|
||||
```
|
||||
|
||||
Install the project:
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
Export the needed environment variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="..."
|
||||
export LLAMA_CLOUD_API_KEY="..."
|
||||
export POSTHOG_API_KEY="..."
|
||||
export POSTHOG_HOST="..."
|
||||
```
|
||||
|
||||
Or add the API keys to the [config file](./configs/config.json).
|
||||
|
||||
You can also modify LlamaParse settings within the configuration file.
|
||||
|
||||
Once the environment variables are set, you can start the web server with:
|
||||
|
||||
```bash
|
||||
webserver-run
|
||||
```
|
||||
|
||||
This will run the web server locally on port 8000. Optionally you can provide a `--port`, `--host` and `--log-level` value to configure the web server.
|
||||
|
||||
You can then upload two or three files containing tech products specs (such as the ones in [data](./data)) and compare them with a custom prompt.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"llm": {
|
||||
"posthog_api_key": "$POSTHOG_API_KEY",
|
||||
"posthog_host": "$POSTHOG_HOST",
|
||||
"openai_api_key": "$OPENAI_API_KEY",
|
||||
"model": "gpt-4.1"
|
||||
},
|
||||
"llama_cloud": {
|
||||
"llama_cloud_api_key": "$LLAMA_CLOUD_API_KEY",
|
||||
"llama_cloud_base_url": null
|
||||
},
|
||||
"llama_parse": {
|
||||
"tier": "agentic",
|
||||
"version": "latest",
|
||||
"custom_prompt": "You are tasked with extracting product specifications from technical spec-sheets.",
|
||||
"output_tables_as_markdown": true,
|
||||
"high_res_ocr": true,
|
||||
"outlined_table_extraction": true,
|
||||
"expand": ["markdown"]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.9.10,<0.10.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "product-specs-comparison"
|
||||
version = "0.1.0"
|
||||
description = "AI agent to produce meaningful product comparisons"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiofiles>=25.1.0",
|
||||
"fastapi>=0.128.7",
|
||||
"llama-cloud>=1.3.0",
|
||||
"llama-index-llms-openai>=0.6.18",
|
||||
"llama-index-workflows>=2.14.1",
|
||||
"posthog>=7.8.5",
|
||||
"python-multipart>=0.0.22",
|
||||
"uvicorn>=0.40.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
webserver-run = "product_specs_comparison.api:main"
|
||||
@@ -0,0 +1,114 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import mimetypes
|
||||
import uuid
|
||||
|
||||
import aiofiles
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, File, Form, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from .shared import ProductComparison, TransferFile
|
||||
from .workflow import ComparisonEvent, InputEvent, ProductSpecsComparisonWorkflow
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
async def _render_html_template(product_comparison: ProductComparison) -> str:
|
||||
env = Environment(
|
||||
loader=FileSystemLoader("./"), autoescape=select_autoescape(), enable_async=True
|
||||
)
|
||||
if product_comparison.best_for_user is not None:
|
||||
template = await asyncio.to_thread(
|
||||
env.get_template, "./static/comparison_with_rec.html"
|
||||
)
|
||||
return await template.render_async(
|
||||
general_comparison=product_comparison.general_comparison,
|
||||
best_for_user=product_comparison.best_for_user,
|
||||
)
|
||||
template = await asyncio.to_thread(env.get_template, "./static/comparison.html")
|
||||
return await template.render_async(
|
||||
general_comparison=product_comparison.general_comparison,
|
||||
)
|
||||
|
||||
|
||||
async def _render_error_template(error: str) -> str:
|
||||
env = Environment(
|
||||
loader=FileSystemLoader("./"), autoescape=select_autoescape(), enable_async=True
|
||||
)
|
||||
template = await asyncio.to_thread(env.get_template, "./static/error.html")
|
||||
return await template.render_async(
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/comparisons")
|
||||
async def run_comparison_workflow(
|
||||
pdf1: UploadFile = File(...),
|
||||
pdf2: UploadFile = File(...),
|
||||
pdf3: UploadFile | None = File(None),
|
||||
prompt: str = Form(...),
|
||||
) -> HTMLResponse:
|
||||
files = [f for f in [pdf1, pdf2, pdf3] if f is not None]
|
||||
|
||||
transfer_files: list[TransferFile] = []
|
||||
for file in files:
|
||||
content = await file.read()
|
||||
if file.content_type is None:
|
||||
file_extension = ".pdf"
|
||||
else:
|
||||
file_extension = mimetypes.guess_extension(file.content_type) or ".pdf"
|
||||
if file.filename is None:
|
||||
file_name = "product-spec" + str(uuid.uuid4())[:8] + file_extension
|
||||
else:
|
||||
file_name = file.filename
|
||||
transfer_files.append(
|
||||
TransferFile(
|
||||
file_content=base64.b64encode(content).decode("utf-8"),
|
||||
file_name=file_name,
|
||||
file_type=file.content_type or "application/pdf",
|
||||
)
|
||||
)
|
||||
input_event = InputEvent(files=transfer_files, prompt=prompt)
|
||||
wf = ProductSpecsComparisonWorkflow(timeout=600)
|
||||
result = await wf.run(start_event=input_event)
|
||||
assert isinstance(result, ComparisonEvent), f"Result is of type: {type(result)}"
|
||||
if result.error is not None:
|
||||
html_content = await _render_error_template(result.error)
|
||||
else:
|
||||
product_comparison = ProductComparison(
|
||||
general_comparison=result.general_comparison or "no general comparison",
|
||||
best_for_user=result.best_for_user,
|
||||
)
|
||||
html_content = await _render_html_template(product_comparison)
|
||||
return HTMLResponse(content=html_content, media_type="text/html")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def home() -> HTMLResponse:
|
||||
async with aiofiles.open("./static/index.html") as f:
|
||||
content = await f.read()
|
||||
return HTMLResponse(content=content, media_type="text/html")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run the product comparison server")
|
||||
parser.add_argument(
|
||||
"--host", type=str, default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
type=str,
|
||||
default="info",
|
||||
help="Log level for the web server",
|
||||
choices=["critical", "error", "warning", "info", "debug", "trace"],
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
uvicorn.run(app, host=args.host, port=args.port)
|
||||
@@ -0,0 +1,3 @@
|
||||
DEFAULT_OPENAI_MODEL = "gpt-5.1"
|
||||
DEFAULT_LLAMA_CLOUD_BASE_URL = "https://api.cloud.llamaindex.ai"
|
||||
FAILURE_STATUSES = ("FAILED", "CANCELLED")
|
||||
@@ -0,0 +1,118 @@
|
||||
import os
|
||||
from typing import Annotated, Literal, cast
|
||||
|
||||
from llama_cloud import AsyncLlamaCloud
|
||||
from llama_index.core.llms.structured_llm import StructuredLLM
|
||||
from llama_index.llms.openai import OpenAI as LlamaOpenAI
|
||||
from posthog import Posthog
|
||||
from posthog.ai.openai import OpenAI
|
||||
from pydantic import BaseModel
|
||||
from workflows.resource import ResourceConfig
|
||||
|
||||
from .constants import DEFAULT_LLAMA_CLOUD_BASE_URL, DEFAULT_OPENAI_MODEL
|
||||
from .shared import ProductComparison
|
||||
|
||||
|
||||
class LlmConfig(BaseModel):
|
||||
posthog_api_key: str
|
||||
posthog_host: str
|
||||
openai_api_key: str
|
||||
model: str | None
|
||||
|
||||
def load(self) -> None:
|
||||
if self.posthog_api_key.startswith("$"):
|
||||
posthog_api_key = os.getenv(self.posthog_api_key.lstrip("$"))
|
||||
if posthog_api_key is None:
|
||||
raise ValueError(f"{self.posthog_api_key} not found in environment")
|
||||
self.posthog_api_key = posthog_api_key
|
||||
if self.openai_api_key.startswith("$"):
|
||||
openai_api_key = os.getenv(self.openai_api_key.lstrip("$"))
|
||||
if openai_api_key is None:
|
||||
raise ValueError(f"{self.openai_api_key} not found in environment")
|
||||
self.openai_api_key = openai_api_key
|
||||
if self.posthog_host.startswith("$"):
|
||||
posthog_host = os.getenv(self.posthog_host.lstrip("$"))
|
||||
if posthog_host is None:
|
||||
raise ValueError(f"{self.posthog_host} not found in environment")
|
||||
self.posthog_host = posthog_host
|
||||
if self.model is None:
|
||||
self.model = DEFAULT_OPENAI_MODEL
|
||||
|
||||
|
||||
class LlamaCloudConfig(BaseModel):
|
||||
llama_cloud_api_key: str
|
||||
llama_cloud_base_url: str | None
|
||||
|
||||
def load(self) -> None:
|
||||
if self.llama_cloud_api_key.startswith("$"):
|
||||
llama_cloud_api_key = os.getenv(self.llama_cloud_api_key.lstrip("$"))
|
||||
if llama_cloud_api_key is None:
|
||||
raise ValueError(f"{self.llama_cloud_api_key} not found in environment")
|
||||
self.llama_cloud_api_key = llama_cloud_api_key
|
||||
if self.llama_cloud_base_url is None:
|
||||
self.llama_cloud_base_url = DEFAULT_LLAMA_CLOUD_BASE_URL
|
||||
elif self.llama_cloud_base_url.startswith("$"):
|
||||
llama_cloud_base_url = os.getenv(self.llama_cloud_base_url.lstrip("$"))
|
||||
if llama_cloud_base_url is None:
|
||||
raise ValueError(
|
||||
f"{self.llama_cloud_base_url} not found in environment"
|
||||
)
|
||||
self.llama_cloud_base_url = llama_cloud_base_url
|
||||
|
||||
|
||||
class LlamaParseConfig(BaseModel):
|
||||
tier: Literal["fast", "cost_effective", "agentic", "agentic_plus"]
|
||||
version: Literal["2026-01-08", "2025-12-31", "2025-12-18", "2025-12-11", "latest"]
|
||||
expand: list[str]
|
||||
output_tables_as_markdown: bool
|
||||
high_res_ocr: bool
|
||||
outlined_table_extraction: bool
|
||||
custom_prompt: str
|
||||
|
||||
def add_markdown_if_not_in_expand(self) -> None:
|
||||
if "markdown" not in self.expand:
|
||||
self.expand.append("markdown")
|
||||
|
||||
|
||||
def get_llm(
|
||||
config: Annotated[
|
||||
LlmConfig,
|
||||
ResourceConfig(
|
||||
config_file="configs/config.json",
|
||||
path_selector="llm",
|
||||
label="LLM Config",
|
||||
description="Config for the LLM resource",
|
||||
),
|
||||
],
|
||||
) -> StructuredLLM:
|
||||
config.load()
|
||||
posthog = Posthog(
|
||||
config.posthog_api_key,
|
||||
host=config.posthog_host,
|
||||
)
|
||||
|
||||
openai_client = OpenAI(api_key=config.openai_api_key, posthog_client=posthog)
|
||||
|
||||
llm = LlamaOpenAI(
|
||||
model=cast(str, config.model),
|
||||
api_key=config.openai_api_key,
|
||||
)
|
||||
llm._client = openai_client
|
||||
return llm.as_structured_llm(ProductComparison)
|
||||
|
||||
|
||||
def get_llama_cloud_client(
|
||||
config: Annotated[
|
||||
LlamaCloudConfig,
|
||||
ResourceConfig(
|
||||
config_file="configs/config.json",
|
||||
path_selector="llama_cloud",
|
||||
label="LlamaCloud Config",
|
||||
description="Configuration for LlamaCloud",
|
||||
),
|
||||
],
|
||||
) -> AsyncLlamaCloud:
|
||||
config.load()
|
||||
return AsyncLlamaCloud(
|
||||
api_key=config.llama_cloud_api_key, base_url=config.llama_cloud_base_url
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
import base64
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TransferFile(BaseModel):
|
||||
file_content: str
|
||||
file_name: str
|
||||
file_type: str
|
||||
|
||||
def _load_content(self) -> bytes:
|
||||
return base64.b64decode(self.file_content, validate=True)
|
||||
|
||||
def as_llamacloud_file(self) -> tuple[str, bytes, str]:
|
||||
return (self.file_name, self._load_content(), self.file_type)
|
||||
|
||||
|
||||
class ProductComparison(BaseModel):
|
||||
general_comparison: str = Field(
|
||||
description="General comparison of the products based on their content"
|
||||
)
|
||||
best_for_user: str | None = Field(
|
||||
description="Which product is the best based on the user's request. Report only if the user asks to identify the best product",
|
||||
default=None,
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
from typing import Annotated, cast
|
||||
|
||||
from llama_cloud import AsyncLlamaCloud
|
||||
from llama_cloud.types.parsing_create_params import (
|
||||
OutputOptions,
|
||||
OutputOptionsMarkdown,
|
||||
OutputOptionsMarkdownTables,
|
||||
ProcessingOptions,
|
||||
ProcessingOptionsAutoModeConfiguration,
|
||||
ProcessingOptionsAutoModeConfigurationParsingConf,
|
||||
)
|
||||
from llama_cloud.types.parsing_get_response import MarkdownPageMarkdownResultPage
|
||||
from llama_index.core.base.llms.types import ChatMessage
|
||||
from llama_index.core.llms.structured_llm import StructuredLLM
|
||||
from pydantic import BaseModel
|
||||
from workflows import Context, Workflow, step
|
||||
from workflows.events import Event, StartEvent, StopEvent
|
||||
from workflows.resource import Resource, ResourceConfig
|
||||
|
||||
from .constants import FAILURE_STATUSES
|
||||
from .resources import (
|
||||
LlamaParseConfig,
|
||||
ProductComparison,
|
||||
get_llama_cloud_client,
|
||||
get_llm,
|
||||
)
|
||||
from .shared import TransferFile
|
||||
|
||||
|
||||
class WorkflowState(BaseModel):
|
||||
prompt: str = ""
|
||||
num_files: int = 0
|
||||
file_id_to_file_name: dict[str, str] = {}
|
||||
|
||||
|
||||
class InputEvent(StartEvent):
|
||||
files: list[TransferFile]
|
||||
prompt: str
|
||||
|
||||
|
||||
class FileUploadedEvent(Event):
|
||||
file_id: str
|
||||
|
||||
|
||||
class FileParsedEvent(Event):
|
||||
content: str | None = None
|
||||
file_id: str
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ComparisonEvent(StopEvent):
|
||||
general_comparison: str | None = None
|
||||
best_for_user: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ProductSpecsComparisonWorkflow(Workflow):
|
||||
@step
|
||||
async def upload_to_llamacloud(
|
||||
self,
|
||||
ev: InputEvent,
|
||||
ctx: Context[WorkflowState],
|
||||
llama_cloud_client: Annotated[
|
||||
AsyncLlamaCloud, Resource(get_llama_cloud_client)
|
||||
],
|
||||
) -> ComparisonEvent | None | FileUploadedEvent:
|
||||
async with ctx.store.edit_state() as state:
|
||||
state.num_files = len(ev.files)
|
||||
state.prompt = ev.prompt
|
||||
for file in ev.files:
|
||||
try:
|
||||
to_uplaod = file.as_llamacloud_file()
|
||||
file_obj = await llama_cloud_client.files.create(
|
||||
file=to_uplaod,
|
||||
purpose="parse",
|
||||
)
|
||||
state.file_id_to_file_name[file_obj.id] = to_uplaod[0]
|
||||
print(
|
||||
f"Uploaded file {to_uplaod[0]} with ID: {file_obj.id}",
|
||||
flush=True,
|
||||
)
|
||||
ctx.send_event(FileUploadedEvent(file_id=file_obj.id))
|
||||
except Exception as e:
|
||||
return ComparisonEvent(
|
||||
error=f"An error occurred while uploading files: {e}"
|
||||
)
|
||||
|
||||
@step(num_workers=4)
|
||||
async def parse_file(
|
||||
self,
|
||||
ev: FileUploadedEvent,
|
||||
ctx: Context[WorkflowState],
|
||||
llama_cloud_client: Annotated[
|
||||
AsyncLlamaCloud, Resource(get_llama_cloud_client)
|
||||
],
|
||||
llama_parse_config: Annotated[
|
||||
LlamaParseConfig,
|
||||
ResourceConfig(
|
||||
config_file="configs/config.json",
|
||||
path_selector="llama_parse",
|
||||
label="LlamaParse Config",
|
||||
description="Config for LlamaParse input/output control",
|
||||
),
|
||||
],
|
||||
) -> FileParsedEvent:
|
||||
llama_parse_config.add_markdown_if_not_in_expand()
|
||||
try:
|
||||
result = await llama_cloud_client.parsing.parse(
|
||||
tier=llama_parse_config.tier,
|
||||
version=llama_parse_config.version,
|
||||
expand=llama_parse_config.expand,
|
||||
file_id=ev.file_id,
|
||||
output_options=OutputOptions(
|
||||
markdown=OutputOptionsMarkdown(
|
||||
tables=OutputOptionsMarkdownTables(
|
||||
output_tables_as_markdown=llama_parse_config.output_tables_as_markdown
|
||||
)
|
||||
)
|
||||
),
|
||||
processing_options=ProcessingOptions(
|
||||
auto_mode_configuration=[
|
||||
ProcessingOptionsAutoModeConfiguration(
|
||||
parsing_conf=ProcessingOptionsAutoModeConfigurationParsingConf(
|
||||
high_res_ocr=llama_parse_config.high_res_ocr,
|
||||
outlined_table_extraction=llama_parse_config.outlined_table_extraction,
|
||||
custom_prompt=llama_parse_config.custom_prompt,
|
||||
)
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
return FileParsedEvent(
|
||||
error=f"An error occurred while parsing the file: {e}",
|
||||
file_id=ev.file_id,
|
||||
)
|
||||
else:
|
||||
if result.job.status in FAILURE_STATUSES:
|
||||
return FileParsedEvent(
|
||||
error=result.job.error_message
|
||||
or f"Parsing job for file {ev.file_id} finished with status: {result.job.status}",
|
||||
file_id=ev.file_id,
|
||||
)
|
||||
if result.markdown is not None:
|
||||
print(
|
||||
f"Parsed file with ID: {ev.file_id}",
|
||||
flush=True,
|
||||
)
|
||||
markdown = "\n\n".join(
|
||||
[
|
||||
page.markdown
|
||||
for page in result.markdown.pages
|
||||
if isinstance(page, MarkdownPageMarkdownResultPage)
|
||||
]
|
||||
)
|
||||
return FileParsedEvent(
|
||||
content=markdown,
|
||||
file_id=ev.file_id,
|
||||
)
|
||||
return FileParsedEvent(
|
||||
error="No content extracted from the file", file_id=ev.file_id
|
||||
)
|
||||
|
||||
@step
|
||||
async def produce_comparison(
|
||||
self,
|
||||
ev: FileParsedEvent,
|
||||
ctx: Context[WorkflowState],
|
||||
llm: Annotated[StructuredLLM, Resource(get_llm)],
|
||||
) -> ComparisonEvent | None:
|
||||
state = await ctx.store.get_state()
|
||||
events = ctx.collect_events(ev, [FileParsedEvent] * state.num_files)
|
||||
if events is None:
|
||||
return None
|
||||
full_content = "# Products to compare\n\n"
|
||||
events = cast(list[FileParsedEvent], events)
|
||||
for event in events:
|
||||
file_name = state.file_id_to_file_name[event.file_id]
|
||||
if event.content is not None:
|
||||
full_content += f"## {file_name}\n\n{event.content[:20_000]}\n\n"
|
||||
else:
|
||||
print(
|
||||
f"Error in file {file_name}: {event.error or 'unidentified error'}. Skipping...",
|
||||
flush=True,
|
||||
)
|
||||
if full_content == "# Products to compare\n\n":
|
||||
return ComparisonEvent(error="No products to compare")
|
||||
prompt = f"{full_content}\n\nBased on the products to compare, can you produce a comparison of their specs that satisfies this user's request? {state.prompt}"
|
||||
try:
|
||||
result = await llm.achat(
|
||||
messages=[ChatMessage(content=prompt, role="user")]
|
||||
)
|
||||
except Exception as e:
|
||||
return ComparisonEvent(
|
||||
error=f"An error occurred while generating the comparison: {e}"
|
||||
)
|
||||
else:
|
||||
if result.message.content is not None:
|
||||
response = ProductComparison.model_validate_json(result.message.content)
|
||||
return ComparisonEvent(
|
||||
general_comparison=response.general_comparison,
|
||||
best_for_user=response.best_for_user,
|
||||
)
|
||||
return ComparisonEvent(error="No comparison produced")
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="space-y-6">
|
||||
<!-- General Comparison Section -->
|
||||
<div
|
||||
class="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-200 shadow-sm"
|
||||
>
|
||||
<h3 class="text-xl font-semibold text-slate-800 mb-3">
|
||||
General Comparison
|
||||
</h3>
|
||||
<div class="text-slate-700 leading-relaxed whitespace-pre-wrap">
|
||||
{{ general_comparison }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<div class="space-y-6">
|
||||
<!-- General Comparison Section -->
|
||||
<div
|
||||
class="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-200 shadow-sm"
|
||||
>
|
||||
<h3 class="text-xl font-semibold text-slate-800 mb-3">
|
||||
General Comparison
|
||||
</h3>
|
||||
<div class="text-slate-700 leading-relaxed whitespace-pre-wrap">
|
||||
{{ general_comparison }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Best For User Section -->
|
||||
<div
|
||||
class="bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl p-6 border border-green-200 shadow-sm"
|
||||
>
|
||||
<h3 class="text-xl font-semibold text-slate-800 mb-3">
|
||||
Recommendation
|
||||
</h3>
|
||||
<div class="text-slate-700 leading-relaxed whitespace-pre-wrap">
|
||||
{{ best_for_user }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="space-y-6">
|
||||
<!-- General Comparison Section -->
|
||||
<div
|
||||
class="bg-gradient-to-br from-red-50 to-red-150 rounded-xl p-6 border border-red-200 shadow-sm"
|
||||
>
|
||||
<h3 class="text-xl font-semibold text-slate-800 mb-3">
|
||||
An error occurred
|
||||
</h3>
|
||||
<div class="text-slate-700 leading-relaxed whitespace-pre-wrap">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,488 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Compare technical specifications across multiple product datasheets"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="product comparison, specs, datasheet, pdf, analysis"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>
|
||||
Product Specs Comparison - Compare Tech Product Specifications
|
||||
</title>
|
||||
<script src="//unpkg.com/alpinejs" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
|
||||
<style type="text/tailwindcss">
|
||||
@theme {
|
||||
--font-inter: Inter, sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-slate-50 to-blue-50 min-h-screen">
|
||||
<header class="items-center text-center space-y-6 px-4">
|
||||
<nav
|
||||
class="max-w-5xl mx-auto grid grid-cols-2 md:grid-cols-2 gap-4 rounded-xl border border-slate-200 bg-white shadow-lg py-6 mt-8 mb-8"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
href="/"
|
||||
class="font-inter text-2xl font-bold text-blue-600 hover:text-blue-700 transition-colors"
|
||||
>Product Specs Comparison</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/run-llama/product-specs-comparison"
|
||||
class="font-inter text-lg text-slate-600 hover:text-blue-600 transition-colors"
|
||||
>Star on GitHub</a
|
||||
>
|
||||
</div>
|
||||
</nav>
|
||||
<h1 class="font-inter text-4xl font-bold text-slate-800">
|
||||
Product Specs Comparison
|
||||
</h1>
|
||||
<h2 class="font-inter text-xl text-slate-600 max-w-2xl mx-auto">
|
||||
Upload product datasheets and get AI-powered comparisons of
|
||||
technical specifications
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
files: [null, null, null],
|
||||
fileNames: [null, null, null],
|
||||
prompt: '',
|
||||
setFile(index, file) {
|
||||
if (file) {
|
||||
this.files[index] = file;
|
||||
this.fileNames[index] = file.name;
|
||||
} else {
|
||||
this.files[index] = null;
|
||||
this.fileNames[index] = null;
|
||||
}
|
||||
},
|
||||
clearFile(index) {
|
||||
this.files[index] = null;
|
||||
this.fileNames[index] = null;
|
||||
const refName = 'fileInput' + index;
|
||||
if (this.$refs[refName]) {
|
||||
this.$refs[refName].value = '';
|
||||
}
|
||||
},
|
||||
clearAll() {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
this.clearFile(i);
|
||||
}
|
||||
this.prompt = '';
|
||||
this.$refs.resultsDisplay.innerHTML = '';
|
||||
},
|
||||
getUploadedCount() {
|
||||
return this.files.filter(f => f !== null).length;
|
||||
}
|
||||
}"
|
||||
class="flex flex-col items-center justify-start p-6 w-full min-h-screen"
|
||||
>
|
||||
<div
|
||||
class="max-w-5xl w-full bg-white rounded-xl shadow-lg p-8 space-y-8"
|
||||
>
|
||||
<!-- Instructions -->
|
||||
<div class="bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
|
||||
<h3 class="font-inter font-semibold text-blue-900 mb-2">
|
||||
How it works:
|
||||
</h3>
|
||||
<ol
|
||||
class="list-decimal list-inside space-y-1 text-sm text-blue-800"
|
||||
>
|
||||
<li>Upload 2-3 product datasheets (PDF format)</li>
|
||||
<li>
|
||||
Enter your comparison question or leave blank for
|
||||
general comparison
|
||||
</li>
|
||||
<li>
|
||||
Click "Compare Products" to get AI-powered insights
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<form
|
||||
hx-post="/comparisons"
|
||||
hx-target="#comparisonResults"
|
||||
hx-trigger="submit"
|
||||
hx-encoding="multipart/form-data"
|
||||
enctype="multipart/form-data"
|
||||
class="w-full space-y-6"
|
||||
>
|
||||
<!-- File Upload Section -->
|
||||
<div class="space-y-4">
|
||||
<label
|
||||
class="block font-inter font-semibold text-slate-700 text-lg"
|
||||
>Upload Product Datasheets (PDF)</label
|
||||
>
|
||||
|
||||
<!-- File Upload Slots -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Product 1 -->
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-4 transition-all"
|
||||
:class="fileNames[0] ? 'border-green-400 bg-green-50' : 'border-slate-300 bg-slate-50'"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="pdf1"
|
||||
name="pdf1"
|
||||
accept="application/pdf"
|
||||
x-ref="fileInput0"
|
||||
@change="setFile(0, $event.target.files[0])"
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<div x-show="!fileNames[0]" class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.fileInput0.click()"
|
||||
class="w-full py-8 px-4 bg-white border-2 border-slate-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all flex flex-col items-center justify-center gap-2 group"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-slate-400 group-hover:text-blue-500"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="18"
|
||||
x2="12"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="9"
|
||||
y1="15"
|
||||
x2="15"
|
||||
y2="15"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-sm font-medium text-slate-600 group-hover:text-blue-600"
|
||||
>Product 1</span
|
||||
>
|
||||
<span class="text-xs text-slate-500"
|
||||
>Click to upload</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="fileNames[0]" class="space-y-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="text-green-600 flex-shrink-0 mt-0.5"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span
|
||||
class="text-sm text-slate-700 break-all flex-1"
|
||||
x-text="fileNames[0]"
|
||||
></span>
|
||||
</div>
|
||||
<button
|
||||
@click="clearFile(0)"
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product 2 -->
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-4 transition-all"
|
||||
:class="fileNames[1] ? 'border-green-400 bg-green-50' : 'border-slate-300 bg-slate-50'"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="pdf2"
|
||||
name="pdf2"
|
||||
accept="application/pdf"
|
||||
x-ref="fileInput1"
|
||||
@change="setFile(1, $event.target.files[0])"
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<div x-show="!fileNames[1]" class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.fileInput1.click()"
|
||||
class="w-full py-8 px-4 bg-white border-2 border-slate-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all flex flex-col items-center justify-center gap-2 group"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-slate-400 group-hover:text-blue-500"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="18"
|
||||
x2="12"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="9"
|
||||
y1="15"
|
||||
x2="15"
|
||||
y2="15"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-sm font-medium text-slate-600 group-hover:text-blue-600"
|
||||
>Product 2</span
|
||||
>
|
||||
<span class="text-xs text-slate-500"
|
||||
>Click to upload</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="fileNames[1]" class="space-y-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="text-green-600 flex-shrink-0 mt-0.5"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span
|
||||
class="text-sm text-slate-700 break-all flex-1"
|
||||
x-text="fileNames[1]"
|
||||
></span>
|
||||
</div>
|
||||
<button
|
||||
@click="clearFile(1)"
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product 3 -->
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-4 transition-all"
|
||||
:class="fileNames[2] ? 'border-green-400 bg-green-50' : 'border-slate-300 bg-slate-50'"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="pdf3"
|
||||
name="pdf3"
|
||||
accept="application/pdf"
|
||||
x-ref="fileInput2"
|
||||
@change="setFile(2, $event.target.files[0])"
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<div x-show="!fileNames[2]" class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.fileInput2.click()"
|
||||
class="w-full py-8 px-4 bg-white border-2 border-slate-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all flex flex-col items-center justify-center gap-2 group"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-slate-400 group-hover:text-blue-500"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="18"
|
||||
x2="12"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="9"
|
||||
y1="15"
|
||||
x2="15"
|
||||
y2="15"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-sm font-medium text-slate-600 group-hover:text-blue-600"
|
||||
>Product 3</span
|
||||
>
|
||||
<span class="text-xs text-slate-500"
|
||||
>Click to upload</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="fileNames[2]" class="space-y-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="text-green-600 flex-shrink-0 mt-0.5"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span
|
||||
class="text-sm text-slate-700 break-all flex-1"
|
||||
x-text="fileNames[2]"
|
||||
></span>
|
||||
</div>
|
||||
<button
|
||||
@click="clearFile(2)"
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-slate-500 text-center">
|
||||
<span x-text="getUploadedCount()"></span> of 3 files
|
||||
uploaded
|
||||
<template x-if="getUploadedCount() < 2">
|
||||
<span class="text-orange-600 font-medium"
|
||||
>(Minimum 2 required)</span
|
||||
>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Input Section -->
|
||||
<div class="space-y-3">
|
||||
<label
|
||||
for="prompt"
|
||||
class="block font-inter font-semibold text-slate-700 text-lg"
|
||||
>
|
||||
Comparison Question
|
||||
</label>
|
||||
<textarea
|
||||
id="prompt"
|
||||
name="prompt"
|
||||
x-model="prompt"
|
||||
rows="4"
|
||||
placeholder="e.g., Which product has better battery life? Compare processing power. What are the key differences in connectivity options?"
|
||||
class="w-full px-4 py-3 border-2 border-slate-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all resize-none font-inter"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-4 justify-center pt-4">
|
||||
<button
|
||||
@click="clearAll()"
|
||||
type="button"
|
||||
class="px-6 py-3 bg-slate-500 text-white rounded-lg hover:bg-slate-600 transition-colors font-medium"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="getUploadedCount() < 2"
|
||||
class="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:bg-slate-300 disabled:cursor-not-allowed shadow-lg hover:shadow-xl"
|
||||
hx-indicator="#loading"
|
||||
>
|
||||
Compare Products
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div
|
||||
id="loading"
|
||||
class="htmx-indicator flex justify-center items-center gap-3 py-4"
|
||||
>
|
||||
<div
|
||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"
|
||||
></div>
|
||||
<span class="text-slate-600 font-medium"
|
||||
>Analyzing documents...</span
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div
|
||||
id="comparisonResults"
|
||||
class="mt-8"
|
||||
x-ref="resultsDisplay"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-12 text-center text-sm text-slate-500 pb-8">
|
||||
<p>
|
||||
Powered by AI • Upload PDF datasheets to compare technical
|
||||
specifications
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user