Spaces:
Sleeping
Sleeping
Commit
·
8d60e33
0
Parent(s):
First commit
Browse files- .gitattributes +1 -0
- .github/workflows/sync-to-hf-space.yml +53 -0
- .gitignore +37 -0
- Dockerfile +34 -0
- LICENSE +176 -0
- Makefile +124 -0
- README.md +140 -0
- app/__init__.py +0 -0
- app/bootstrap.py +22 -0
- app/core/__init__.py +0 -0
- app/core/config.py +82 -0
- app/core/inference/__init__.py +3 -0
- app/core/inference/client.py +287 -0
- app/core/inference/providers.py +402 -0
- app/core/logging.py +57 -0
- app/core/prompts/__init__.py +0 -0
- app/core/prompts/plan.txt +17 -0
- app/core/rag/__init__.py +0 -0
- app/core/rag/build.py +300 -0
- app/core/rag/retriever.py +50 -0
- app/core/rate_limit.py +27 -0
- app/core/redact.py +10 -0
- app/core/schema.py +71 -0
- app/deps.py +7 -0
- app/main.py +211 -0
- app/middleware.py +191 -0
- app/routers/__init__.py +0 -0
- app/routers/health.py +14 -0
- app/services/__init__.py +0 -0
- app/services/validator_service.py +358 -0
- app/static/script.js +0 -0
- app/static/styles.css +243 -0
- app/templates/base.html +188 -0
- app/templates/home.html +168 -0
- app/templates/validator.html +208 -0
- app/ui.py +55 -0
- app/validators.py +226 -0
- assets/2025-10-05-00-49-00.png +3 -0
- configs/.env.example +26 -0
- configs/rag_sources.yaml +41 -0
- configs/settings.yaml +35 -0
- pyproject.toml +50 -0
- requirements.txt +33 -0
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
assets/*.png filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/sync-to-hf-space.yml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# .github/workflows/sync-to-hf-space.yml
|
| 2 |
+
name: Sync to Hugging Face Space (a2a-validator)
|
| 3 |
+
|
| 4 |
+
on:
|
| 5 |
+
push:
|
| 6 |
+
branches: ["main", "master"]
|
| 7 |
+
workflow_dispatch: {} # optional manual run
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
sync-to-hub:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
concurrency:
|
| 13 |
+
group: hf-space-sync-a2a-validator
|
| 14 |
+
cancel-in-progress: false
|
| 15 |
+
|
| 16 |
+
# Hard-wire your Space coordinates here
|
| 17 |
+
env:
|
| 18 |
+
HF_USERNAME: ruslanmv
|
| 19 |
+
SPACE_NAME: a2a-validator
|
| 20 |
+
|
| 21 |
+
steps:
|
| 22 |
+
- name: Checkout
|
| 23 |
+
uses: actions/checkout@v4
|
| 24 |
+
with:
|
| 25 |
+
fetch-depth: 0
|
| 26 |
+
lfs: true
|
| 27 |
+
|
| 28 |
+
- name: Prepare Git
|
| 29 |
+
run: |
|
| 30 |
+
git config user.name "github-actions[bot]"
|
| 31 |
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
| 32 |
+
git lfs install --local
|
| 33 |
+
|
| 34 |
+
- name: Force push GitHub → Hugging Face Space (GitHub is source of truth)
|
| 35 |
+
env:
|
| 36 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 37 |
+
run: |
|
| 38 |
+
set -e
|
| 39 |
+
if [ -z "$HF_TOKEN" ]; then
|
| 40 |
+
echo "❌ Missing HF_TOKEN secret. Add it under: Settings → Secrets and variables → Actions → New repository secret."
|
| 41 |
+
exit 1
|
| 42 |
+
fi
|
| 43 |
+
|
| 44 |
+
# Build the authenticated remote URL *inside* the step so the secret expands.
|
| 45 |
+
REMOTE_URL="https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/spaces/${HF_USERNAME}/${SPACE_NAME}"
|
| 46 |
+
|
| 47 |
+
echo "🔁 Forcing HEAD → Space main (GitHub is the source of truth)..."
|
| 48 |
+
git push --force "$REMOTE_URL" HEAD:main
|
| 49 |
+
|
| 50 |
+
# Best-effort push for LFS objects (won't fail the job if none)
|
| 51 |
+
git lfs push --all "$REMOTE_URL" main || true
|
| 52 |
+
|
| 53 |
+
echo "✅ Sync complete: GitHub HEAD is now Space main."
|
.gitignore
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python artifacts
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
dist/
|
| 9 |
+
*.egg-info/
|
| 10 |
+
|
| 11 |
+
# Virtual environments
|
| 12 |
+
.venv/
|
| 13 |
+
venv/
|
| 14 |
+
ENV/
|
| 15 |
+
|
| 16 |
+
# Environment files
|
| 17 |
+
.env
|
| 18 |
+
|
| 19 |
+
# Test & coverage reports
|
| 20 |
+
.cache/
|
| 21 |
+
.pytest_cache/
|
| 22 |
+
.mypy_cache/
|
| 23 |
+
htmlcov/
|
| 24 |
+
.coverage
|
| 25 |
+
|
| 26 |
+
# IDE & OS files
|
| 27 |
+
.idea/
|
| 28 |
+
.vscode/
|
| 29 |
+
.DS_Store
|
| 30 |
+
Thumbs.db
|
| 31 |
+
|
| 32 |
+
# RAG index files
|
| 33 |
+
.faiss/
|
| 34 |
+
/backup
|
| 35 |
+
copy *.*
|
| 36 |
+
* copy.*
|
| 37 |
+
* copy *.py
|
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# --- base env ---
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PYTHONUNBUFFERED=1 \
|
| 7 |
+
PIP_NO_CACHE_DIR=1
|
| 8 |
+
|
| 9 |
+
# --- system deps ---
|
| 10 |
+
RUN apt-get update \
|
| 11 |
+
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# --- app dir ---
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
|
| 17 |
+
# --- python deps (cache friendly layer) ---
|
| 18 |
+
COPY requirements.txt ./
|
| 19 |
+
RUN pip install --upgrade pip && pip install -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# --- copy app ---
|
| 22 |
+
COPY . .
|
| 23 |
+
|
| 24 |
+
# Hugging Face sets $PORT at runtime; keep a sane default for local runs
|
| 25 |
+
ENV PORT=7860
|
| 26 |
+
EXPOSE 7860
|
| 27 |
+
|
| 28 |
+
# Optional: run as non-root
|
| 29 |
+
# RUN useradd -ms /bin/bash appuser && chown -R appuser:appuser /app
|
| 30 |
+
# USER appuser
|
| 31 |
+
|
| 32 |
+
# --- start (shell form so $PORT expands) ---
|
| 33 |
+
# --proxy-headers is helpful behind HF’s proxy
|
| 34 |
+
CMD uvicorn app.main:app --host 0.0.0.0 --port $PORT --proxy-headers
|
LICENSE
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
Makefile
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ====================================================================================
|
| 2 |
+
#
|
| 3 |
+
# M A T R I X - A I ::: C O N T R O L P R O G R A M
|
| 4 |
+
# "Know thyself."
|
| 5 |
+
#
|
| 6 |
+
# Access programs with: make help
|
| 7 |
+
#
|
| 8 |
+
# ====================================================================================
|
| 9 |
+
|
| 10 |
+
# System & Colors
|
| 11 |
+
BRIGHT_GREEN := $(shell tput -T screen setaf 10)
|
| 12 |
+
DIM_GREEN := $(shell tput -T screen setaf 2)
|
| 13 |
+
RESET := $(shell tput -T screen sgr0)
|
| 14 |
+
|
| 15 |
+
# Python / Venv
|
| 16 |
+
SYS_PYTHON := python3
|
| 17 |
+
VENV_DIR := .venv
|
| 18 |
+
PYTHON := $(VENV_DIR)/bin/python
|
| 19 |
+
PIP := $(PYTHON) -m pip
|
| 20 |
+
|
| 21 |
+
# App
|
| 22 |
+
APP_MODULE := app.main:app
|
| 23 |
+
PORT := 7860
|
| 24 |
+
|
| 25 |
+
# Docker / HF Spaces
|
| 26 |
+
IMG_NAME := a2a-validator:local
|
| 27 |
+
SPACE_URL ?= https://huggingface.co/spaces/ruslanmv/a2a-validator
|
| 28 |
+
|
| 29 |
+
# Files & Dirs
|
| 30 |
+
REQ := requirements.txt
|
| 31 |
+
TEST_DIR := tests
|
| 32 |
+
|
| 33 |
+
.DEFAULT_GOAL := help
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Help
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
help:
|
| 39 |
+
@echo
|
| 40 |
+
@echo "$(BRIGHT_GREEN)M A T R I X - A I ::: C O N T R O L P R O G R A M$(RESET)"
|
| 41 |
+
@echo
|
| 42 |
+
@printf "$(BRIGHT_GREEN) %-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "PROGRAM" "DESCRIPTION"
|
| 43 |
+
@printf "$(BRIGHT_GREEN) %-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "----------------------" "--------------------------------------------------------"
|
| 44 |
+
@echo
|
| 45 |
+
@echo "$(BRIGHT_GREEN)Environment$(RESET)"
|
| 46 |
+
@printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "venv" "Create virtualenv (.venv)"
|
| 47 |
+
@printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "install" "Install deps into venv (incremental)"
|
| 48 |
+
@echo
|
| 49 |
+
@echo "$(BRIGHT_GREEN)Quality$(RESET)"
|
| 50 |
+
@printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "lint" "ruff check"
|
| 51 |
+
@printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "fmt" "black + ruff fix"
|
| 52 |
+
@printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "test" "pytest"
|
| 53 |
+
@echo
|
| 54 |
+
@echo "$(BRIGHT_GREEN)Run$(RESET)"
|
| 55 |
+
@printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "run" "Run uvicorn (PORT=$(PORT))"
|
| 56 |
+
@printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "run-hot" "Run with --reload"
|
| 57 |
+
@echo
|
| 58 |
+
@echo "$(BRIGHT_GREEN)Docker$(RESET)"
|
| 59 |
+
@printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "docker-build" "Build local image ($(IMG_NAME))"
|
| 60 |
+
@printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "docker-run" "Run local container (maps $(PORT))"
|
| 61 |
+
@echo
|
| 62 |
+
@echo "$(BRIGHT_GREEN)HF Spaces helpers$(RESET)"
|
| 63 |
+
@printf " $(BRIGHT_GREEN)%-22s$(RESET) $(DIM_GREEN)%s$(RESET)\n" "space-url" "Echo the Space URL (set SPACE_URL=...)"
|
| 64 |
+
@echo
|
| 65 |
+
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
# Env
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
$(VENV_DIR)/bin/activate:
|
| 70 |
+
@test -d $(VENV_DIR) || $(SYS_PYTHON) -m venv $(VENV_DIR)
|
| 71 |
+
|
| 72 |
+
venv: $(VENV_DIR)/bin/activate
|
| 73 |
+
@echo "$(DIM_GREEN)-> Upgrading pip/setuptools/wheel$(RESET)"
|
| 74 |
+
@$(PIP) install -U pip setuptools wheel >/dev/null
|
| 75 |
+
|
| 76 |
+
install: venv
|
| 77 |
+
@echo "$(DIM_GREEN)-> Installing deps$(RESET)"
|
| 78 |
+
@$(PIP) install -r $(REQ)
|
| 79 |
+
@echo "$(BRIGHT_GREEN)OK$(RESET)"
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# Quality
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
lint: venv
|
| 85 |
+
@$(PYTHON) -m ruff check app tests || true
|
| 86 |
+
|
| 87 |
+
fmt: venv
|
| 88 |
+
@$(PYTHON) -m black app tests || true
|
| 89 |
+
@$(PYTHON) -m ruff check --fix app tests || true
|
| 90 |
+
|
| 91 |
+
test: venv
|
| 92 |
+
@$(PYTHON) -m pytest -q --disable-warnings --maxfail=1 || true
|
| 93 |
+
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
# Run
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
run: install
|
| 98 |
+
@PORT=$(PORT) $(VENV_DIR)/bin/uvicorn $(APP_MODULE) --host 0.0.0.0 --port $(PORT)
|
| 99 |
+
|
| 100 |
+
run-hot: install
|
| 101 |
+
@PORT=$(PORT) $(VENV_DIR)/bin/uvicorn $(APP_MODULE) --host 0.0.0.0 --port $(PORT) --reload
|
| 102 |
+
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
# Docker
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
docker-build:
|
| 107 |
+
@docker build -t $(IMG_NAME) .
|
| 108 |
+
|
| 109 |
+
docker-run:
|
| 110 |
+
@docker run --rm -it -p $(PORT):$(PORT) -e PORT=$(PORT) $(IMG_NAME)
|
| 111 |
+
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
# HF Helpers
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
space-url:
|
| 116 |
+
@echo "Space: $(SPACE_URL)"
|
| 117 |
+
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
# Clean
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
clean:
|
| 122 |
+
@rm -rf .venv __pycache__ .pytest_cache .ruff_cache .mypy_cache dist build *.egg-info
|
| 123 |
+
|
| 124 |
+
.PHONY: help venv install lint fmt test run run-hot kb kb-force docker-build docker-run space-url clean
|
README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: A2A Validator
|
| 3 |
+
emoji: 🔬
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# A2A Validator 🔬
|
| 11 |
+

|
| 12 |
+
**A2A Validator** is a focused web app for testing AI agents that speak the **A2A (Agent-to-Agent) protocol**. Paste an agent URL, connect, and the app will fetch the agent’s discovery document, validate it against the protocol, and provide a real-time message terminal. It’s a tool designed for the build/integration loop: tight feedback, zero ceremony.
|
| 13 |
+
|
| 14 |
+
The backend runs on **FastAPI** and **Socket.IO**, with a lightweight HTML/JS frontend. The core validator checks both the static **Agent Card** and the live events an agent emits. If the optional `a2a-sdk` is present, it's used for card resolution and streaming; if not, the app gracefully falls back to a "plain HTTP" mode for basic card linting.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## ✨ Features
|
| 19 |
+
|
| 20 |
+
* **Inspector UI**: A clean interface at `/validator` to enter an agent URL and optional custom HTTP headers for auth or tenancy.
|
| 21 |
+
* **Smart Card Resolution**: Forgivingly handles root URLs by following redirects and probing common well-known paths (e.g., `/.well-known/agent.json`).
|
| 22 |
+
* **Inline Validation**: Pretty-prints the agent's JSON card and displays any validation errors or warnings directly in the UI.
|
| 23 |
+
* **Live Chat Terminal**: When the `a2a-sdk` is installed, a real-time terminal connects via Socket.IO to stream messages to and from the agent.
|
| 24 |
+
* **Real-time Message Linting**: Live messages are marked with ✅ (compliant) or ⚠️ (non-compliant). Click any message to view the raw JSON.
|
| 25 |
+
* **Integrated Debug Console**: A resizable console shows raw server-side request/response logs, minimizing the need for browser DevTools.
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## 🚀 Quick Start
|
| 30 |
+
|
| 31 |
+
Create a virtual environment, install dependencies, and run the server:
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
# 1. Create venv and activate
|
| 35 |
+
python3 -m venv .venv
|
| 36 |
+
source .venv/bin/activate
|
| 37 |
+
|
| 38 |
+
# 2. Install dependencies for editable mode
|
| 39 |
+
pip install -e .
|
| 40 |
+
|
| 41 |
+
# 3. Run the development server
|
| 42 |
+
make run
|
| 43 |
+
````
|
| 44 |
+
|
| 45 |
+
or
|
| 46 |
+
```bash
|
| 47 |
+
uvicorn app.main:app --host 0.0.0.0 --port 7860 --reload
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
Point your browser to the Inspector UI:
|
| 51 |
+
|
| 52 |
+
➡️ **http://localhost:7860/validator**
|
| 53 |
+
|
| 54 |
+
You can also test the card fetch endpoint directly from your terminal:
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
curl -s -X POST localhost:7860/agent-card \
|
| 58 |
+
-H 'content-type: application/json' \
|
| 59 |
+
-d '{"url":"http://localhost:8080/","sid":"test"}' | jq
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
-----
|
| 63 |
+
|
| 64 |
+
## 🛠️ How It Works
|
| 65 |
+
|
| 66 |
+
The validator checks for common issues that break interoperability.
|
| 67 |
+
|
| 68 |
+
* **Agent Card Validation**: It checks for required fields (`name`, `description`, `url`, `capabilities`, `skills`, etc.), correct types, a semver-like version string, and a valid absolute URL.
|
| 69 |
+
* **Message Validation**: During a live session, it validates incoming events based on their `kind`. For example, `task` events must have an `id` and `status.state`, while `message` events must have a `parts` array and the correct `role`.
|
| 70 |
+
|
| 71 |
+
### Architecture
|
| 72 |
+
|
| 73 |
+
```mermaid
|
| 74 |
+
flowchart LR
|
| 75 |
+
subgraph Browser
|
| 76 |
+
UI[Inspector UI]
|
| 77 |
+
end
|
| 78 |
+
|
| 79 |
+
subgraph Server [FastAPI App]
|
| 80 |
+
API[/POST /agent-card<br/>GET /validator/]
|
| 81 |
+
WS[[Socket.IO]]
|
| 82 |
+
end
|
| 83 |
+
|
| 84 |
+
subgraph Agent [A2A Agent]
|
| 85 |
+
RPC[(JSON-RPC Endpoint)]
|
| 86 |
+
end
|
| 87 |
+
|
| 88 |
+
UI -- HTTP & WebSocket --> Server
|
| 89 |
+
Server -- HTTP & JSON-RPC --> Agent
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
### Sequence Diagram
|
| 93 |
+
|
| 94 |
+
```mermaid
|
| 95 |
+
sequenceDiagram
|
| 96 |
+
participant U as UI
|
| 97 |
+
participant S as Server
|
| 98 |
+
participant A as Agent
|
| 99 |
+
|
| 100 |
+
U->>S: POST /agent-card { url, sid }
|
| 101 |
+
S->>A: Resolve + GET card (follows redirects)
|
| 102 |
+
A-->>S: Card JSON
|
| 103 |
+
S->>S: Validate Card
|
| 104 |
+
S-->>U: { card, validation_errors }
|
| 105 |
+
|
| 106 |
+
U->>S: socket 'initialize_client'
|
| 107 |
+
S-->>U: 'client_initialized'
|
| 108 |
+
U->>S: socket 'send_message' { message }
|
| 109 |
+
S->>A: JSON-RPC call / stream
|
| 110 |
+
A-->>S: Agent responses
|
| 111 |
+
S->>S: Validate each response
|
| 112 |
+
S-->>U: 'agent_response' with validation notes
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
-----
|
| 116 |
+
|
| 117 |
+
## ⚙️ Configuration & Endpoints
|
| 118 |
+
|
| 119 |
+
The application is designed to work out of the box with minimal setup.
|
| 120 |
+
|
| 121 |
+
* `GET /validator`: Serves the Inspector UI.
|
| 122 |
+
* `POST /agent-card`: The API endpoint for fetching and validating an Agent Card.
|
| 123 |
+
* `/socket.io`: The path for real-time WebSocket connections.
|
| 124 |
+
* `/healthz` & `/readyz`: Standard health check endpoints.
|
| 125 |
+
|
| 126 |
+
Static assets are served from `app/static` and templates from `app/templates`. The main UI is `validator.html`, which extends a base template. The server creates an alias for `/agent-card` at the root, so the frontend script works correctly even when the validator is embedded in a larger application.
|
| 127 |
+
|
| 128 |
+
-----
|
| 129 |
+
|
| 130 |
+
## 🔍 Troubleshooting
|
| 131 |
+
|
| 132 |
+
* **307 Temporary Redirect**: If you paste a root URL (e.g., `http://my-agent/`) and see this, it often means the agent is redirecting to a docs page. The validator will automatically try to find the card at common paths like `/.well-known/agent.json`. If it still fails, try pasting the direct URL to the agent card JSON file.
|
| 133 |
+
* **404 Not Found on `/agent-card`**: Ensure the API route alias is correctly configured in your `app/main.py` file. This is crucial if the `validator_service` router is mounted behind a prefix.
|
| 134 |
+
* **"A2A SDK not installed"**: This is not an error but a feature. The app is running in its lightweight, HTTP-only mode. To enable chat and streaming tests, install the `a2a-sdk` package (`pip install "a2a-sdk[http-server]"`).
|
| 135 |
+
|
| 136 |
+
-----
|
| 137 |
+
|
| 138 |
+
## 📜 License
|
| 139 |
+
|
| 140 |
+
This project is licensed under the **Apache-2.0 License**. Use it, tweak it, ship it.
|
app/__init__.py
ADDED
|
File without changes
|
app/bootstrap.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/bootstrap.py
|
| 2 |
+
"""
|
| 3 |
+
App bootstrap: load .env and configure logging as early as possible.
|
| 4 |
+
This module should be imported once at process start (import side-effects).
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
# Load environment from configs/.env if present (non-fatal if missing)
|
| 12 |
+
load_dotenv(dotenv_path=os.path.join("configs", ".env"))
|
| 13 |
+
|
| 14 |
+
# Configure logging after env is loaded so LOG_LEVEL is respected
|
| 15 |
+
try:
|
| 16 |
+
from app.core.logging import setup_logging # noqa: E402
|
| 17 |
+
setup_logging()
|
| 18 |
+
except Exception as e:
|
| 19 |
+
# Fallback to a minimal logger if our setup helper isn't available for any reason
|
| 20 |
+
import logging as _logging
|
| 21 |
+
_logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper())
|
| 22 |
+
_logging.getLogger(__name__).warning("Fallback logging configured: %s", e)
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import os, yaml
|
| 3 |
+
from pydantic import BaseModel, AnyHttpUrl
|
| 4 |
+
from typing import Optional, List
|
| 5 |
+
|
| 6 |
+
class ModelCfg(BaseModel):
|
| 7 |
+
# HF Router defaults (used when we reach the router)
|
| 8 |
+
name: str = "HuggingFaceH4/zephyr-7b-beta"
|
| 9 |
+
fallback: str = "mistralai/Mistral-7B-Instruct-v0.2"
|
| 10 |
+
max_new_tokens: int = 256
|
| 11 |
+
temperature: float = 0.2
|
| 12 |
+
provider: Optional[str] = None # HF Router provider tag (e.g., "featherless-ai")
|
| 13 |
+
|
| 14 |
+
# New: provider-specific default models
|
| 15 |
+
groq_model: str = "llama-3.1-8b-instant"
|
| 16 |
+
gemini_model: str = "gemini-2.5-flash"
|
| 17 |
+
|
| 18 |
+
class LimitsCfg(BaseModel):
|
| 19 |
+
rate_per_min: int = 60
|
| 20 |
+
cache_size: int = 256
|
| 21 |
+
|
| 22 |
+
class RagCfg(BaseModel):
|
| 23 |
+
index_dataset: Optional[str] = None
|
| 24 |
+
top_k: int = 4
|
| 25 |
+
|
| 26 |
+
class MatrixHubCfg(BaseModel):
|
| 27 |
+
base_url: AnyHttpUrl = "https://api.matrixhub.io"
|
| 28 |
+
|
| 29 |
+
class SecurityCfg(BaseModel):
|
| 30 |
+
admin_token: Optional[str] = None
|
| 31 |
+
|
| 32 |
+
class Settings(BaseModel):
|
| 33 |
+
model: ModelCfg = ModelCfg()
|
| 34 |
+
limits: LimitsCfg = LimitsCfg()
|
| 35 |
+
rag: RagCfg = RagCfg()
|
| 36 |
+
matrixhub: MatrixHubCfg = MatrixHubCfg()
|
| 37 |
+
security: SecurityCfg = SecurityCfg()
|
| 38 |
+
|
| 39 |
+
# New
|
| 40 |
+
provider_order: List[str] = ["groq", "gemini", "router"] # cascade order
|
| 41 |
+
chat_backend: str = "multi" # was "router"; "multi" enables cascade
|
| 42 |
+
chat_stream: bool = True
|
| 43 |
+
|
| 44 |
+
@staticmethod
|
| 45 |
+
def load() -> "Settings":
|
| 46 |
+
path = os.getenv("SETTINGS_FILE", "configs/settings.yaml")
|
| 47 |
+
data = {}
|
| 48 |
+
if os.path.exists(path):
|
| 49 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 50 |
+
data = yaml.safe_load(f) or {}
|
| 51 |
+
|
| 52 |
+
settings = Settings.model_validate(data)
|
| 53 |
+
|
| 54 |
+
# Existing env overrides
|
| 55 |
+
if "MODEL_NAME" in os.environ:
|
| 56 |
+
settings.model.name = os.environ["MODEL_NAME"]
|
| 57 |
+
if "MODEL_FALLBACK" in os.environ:
|
| 58 |
+
settings.model.fallback = os.environ["MODEL_FALLBACK"]
|
| 59 |
+
if "MODEL_PROVIDER" in os.environ:
|
| 60 |
+
settings.model.provider = os.environ["MODEL_PROVIDER"]
|
| 61 |
+
if "ADMIN_TOKEN" in os.environ:
|
| 62 |
+
settings.security.admin_token = os.environ["ADMIN_TOKEN"]
|
| 63 |
+
if "RATE_LIMITS" in os.environ:
|
| 64 |
+
settings.limits.rate_per_min = int(os.environ["RATE_LIMITS"])
|
| 65 |
+
if "HF_CHAT_BACKEND" in os.environ:
|
| 66 |
+
settings.chat_backend = os.environ["HF_CHAT_BACKEND"].strip().lower()
|
| 67 |
+
if "CHAT_STREAM" in os.environ:
|
| 68 |
+
settings.chat_stream = os.environ["CHAT_STREAM"].lower() in ("1","true","yes","on")
|
| 69 |
+
|
| 70 |
+
# New env overrides
|
| 71 |
+
if "GROQ_MODEL" in os.environ:
|
| 72 |
+
settings.model.groq_model = os.environ["GROQ_MODEL"]
|
| 73 |
+
if "GEMINI_MODEL" in os.environ:
|
| 74 |
+
settings.model.gemini_model = os.environ["GEMINI_MODEL"]
|
| 75 |
+
if "PROVIDER_ORDER" in os.environ:
|
| 76 |
+
settings.provider_order = [p.strip().lower() for p in os.environ["PROVIDER_ORDER"].split(",") if p.strip()]
|
| 77 |
+
|
| 78 |
+
# Default to cascade
|
| 79 |
+
if settings.chat_backend not in ("multi", "router"):
|
| 80 |
+
settings.chat_backend = "multi"
|
| 81 |
+
|
| 82 |
+
return settings
|
app/core/inference/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .client import ChatClient, chat, get_client
|
| 2 |
+
|
| 3 |
+
__all__ = ["ChatClient", "chat", "get_client"]
|
app/core/inference/client.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/core/inference/client.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Unified chat client module.
|
| 6 |
+
|
| 7 |
+
- Exposes a production-ready MultiProvider cascade client (GROQ → Gemini → HF Router),
|
| 8 |
+
via ChatClient / chat(...).
|
| 9 |
+
- Keeps the legacy RouterRequestsClient for direct access to the HF Router compatible
|
| 10 |
+
/v1/chat/completions endpoint, preserving backward compatibility.
|
| 11 |
+
|
| 12 |
+
This file assumes:
|
| 13 |
+
- app/bootstrap.py exists and loads configs/.env + sets up logging.
|
| 14 |
+
- app/core/config.py provides Settings (with provider_order, etc.).
|
| 15 |
+
- app/core/inference/providers.py implements MultiProviderChat orchestrator.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import json
|
| 20 |
+
import time
|
| 21 |
+
import logging
|
| 22 |
+
from typing import Dict, List, Optional, Iterator, Tuple, Iterable, Union, Generator
|
| 23 |
+
|
| 24 |
+
# Ensure .env & logging before we load settings/providers
|
| 25 |
+
import app.bootstrap # noqa: F401
|
| 26 |
+
|
| 27 |
+
import requests
|
| 28 |
+
|
| 29 |
+
from app.core.config import Settings
|
| 30 |
+
from app.core.inference.providers import MultiProviderChat
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
# -----------------------------
|
| 35 |
+
# Multi-provider cascade client
|
| 36 |
+
# -----------------------------
|
| 37 |
+
|
| 38 |
+
Message = Dict[str, str]
|
| 39 |
+
|
| 40 |
+
class ChatClient:
|
| 41 |
+
"""
|
| 42 |
+
Unified chat client that executes the configured provider cascade.
|
| 43 |
+
Providers are tried in order (settings.provider_order). First success wins.
|
| 44 |
+
"""
|
| 45 |
+
def __init__(self, settings: Settings | None = None):
|
| 46 |
+
self._settings = settings or Settings.load()
|
| 47 |
+
self._chain = MultiProviderChat(self._settings)
|
| 48 |
+
|
| 49 |
+
def chat(
|
| 50 |
+
self,
|
| 51 |
+
messages: Iterable[Message],
|
| 52 |
+
temperature: Optional[float] = None,
|
| 53 |
+
max_new_tokens: Optional[int] = None,
|
| 54 |
+
stream: Optional[bool] = None,
|
| 55 |
+
) -> Union[str, Generator[str, None, None]]:
|
| 56 |
+
"""
|
| 57 |
+
Execute a chat completion using the provider cascade.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
messages: Iterable of {"role": "system|user|assistant", "content": "..."}
|
| 61 |
+
temperature: Optional override for sampling temperature.
|
| 62 |
+
max_new_tokens: Optional override for max tokens.
|
| 63 |
+
stream: If None, uses settings.chat_stream. If True, returns a generator of text chunks.
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
str (non-stream) or generator[str] (stream)
|
| 67 |
+
"""
|
| 68 |
+
use_stream = self._settings.chat_stream if stream is None else bool(stream)
|
| 69 |
+
return self._chain.chat(
|
| 70 |
+
messages,
|
| 71 |
+
temperature=temperature,
|
| 72 |
+
max_new_tokens=max_new_tokens,
|
| 73 |
+
stream=use_stream,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Backward-compatible helpers
|
| 77 |
+
_default_client: ChatClient | None = None
|
| 78 |
+
|
| 79 |
+
def _get_default() -> ChatClient:
|
| 80 |
+
global _default_client
|
| 81 |
+
if _default_client is None:
|
| 82 |
+
_default_client = ChatClient()
|
| 83 |
+
return _default_client
|
| 84 |
+
|
| 85 |
+
def chat(
|
| 86 |
+
messages: Iterable[Message],
|
| 87 |
+
temperature: Optional[float] = None,
|
| 88 |
+
max_new_tokens: Optional[int] = None,
|
| 89 |
+
stream: Optional[bool] = None,
|
| 90 |
+
) -> Union[str, Generator[str, None, None]]:
|
| 91 |
+
"""
|
| 92 |
+
Convenience function using a process-wide default ChatClient.
|
| 93 |
+
"""
|
| 94 |
+
return _get_default().chat(messages, temperature=temperature, max_new_tokens=max_new_tokens, stream=stream)
|
| 95 |
+
|
| 96 |
+
def get_client(settings: Settings | None = None) -> ChatClient:
|
| 97 |
+
"""
|
| 98 |
+
Factory for an explicit ChatClient bound to provided settings.
|
| 99 |
+
"""
|
| 100 |
+
return ChatClient(settings)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ------------------------------------------------------
|
| 104 |
+
# Legacy HF Router client (kept for backward compatibility)
|
| 105 |
+
# ------------------------------------------------------
|
| 106 |
+
|
| 107 |
+
ROUTER_URL = "https://router.huggingface.co/v1/chat/completions"
|
| 108 |
+
|
| 109 |
+
def _require_token() -> str:
|
| 110 |
+
tok = os.getenv("HF_TOKEN")
|
| 111 |
+
if not tok:
|
| 112 |
+
raise ValueError("HF_TOKEN is not set. Put it in .env or export it before starting.")
|
| 113 |
+
return tok
|
| 114 |
+
|
| 115 |
+
def _model_with_provider(model: str, provider: Optional[str]) -> str:
|
| 116 |
+
if provider and ":" not in model:
|
| 117 |
+
return f"{model}:{provider}"
|
| 118 |
+
return model
|
| 119 |
+
|
| 120 |
+
def _mk_messages(system_prompt: Optional[str], user_text: str) -> List[Dict[str, str]]:
|
| 121 |
+
msgs: List[Dict[str, str]] = []
|
| 122 |
+
if system_prompt:
|
| 123 |
+
msgs.append({"role": "system", "content": system_prompt})
|
| 124 |
+
msgs.append({"role": "user", "content": user_text})
|
| 125 |
+
return msgs
|
| 126 |
+
|
| 127 |
+
def _timeout_tuple(connect: float = 10.0, read: float = 60.0) -> Tuple[float, float]:
|
| 128 |
+
return (connect, read)
|
| 129 |
+
|
| 130 |
+
class RouterRequestsClient:
|
| 131 |
+
"""
|
| 132 |
+
Simple requests-only client for HF Router Chat Completions.
|
| 133 |
+
Supports non-streaming (returns str) and streaming (yields token strings).
|
| 134 |
+
|
| 135 |
+
NOTE: New code should prefer ChatClient above. This class is preserved for any
|
| 136 |
+
legacy call sites that rely on direct HF Router access.
|
| 137 |
+
"""
|
| 138 |
+
def __init__(
|
| 139 |
+
self,
|
| 140 |
+
model: str,
|
| 141 |
+
fallback: Optional[str] = None,
|
| 142 |
+
provider: Optional[str] = None,
|
| 143 |
+
max_retries: int = 2,
|
| 144 |
+
connect_timeout: float = 10.0,
|
| 145 |
+
read_timeout: float = 60.0
|
| 146 |
+
):
|
| 147 |
+
self.model = model
|
| 148 |
+
self.fallback = fallback if fallback != model else None
|
| 149 |
+
self.provider = provider
|
| 150 |
+
self.headers = {"Authorization": f"Bearer {_require_token()}"}
|
| 151 |
+
self.max_retries = max(0, int(max_retries))
|
| 152 |
+
self.timeout = _timeout_tuple(connect_timeout, read_timeout)
|
| 153 |
+
|
| 154 |
+
# -------- Non-stream (single text) --------
|
| 155 |
+
def chat_nonstream(
|
| 156 |
+
self,
|
| 157 |
+
system_prompt: Optional[str],
|
| 158 |
+
user_text: str,
|
| 159 |
+
max_tokens: int,
|
| 160 |
+
temperature: float,
|
| 161 |
+
stop: Optional[List[str]] = None,
|
| 162 |
+
frequency_penalty: Optional[float] = None,
|
| 163 |
+
presence_penalty: Optional[float] = None,
|
| 164 |
+
) -> str:
|
| 165 |
+
payload = {
|
| 166 |
+
"model": _model_with_provider(self.model, self.provider),
|
| 167 |
+
"messages": _mk_messages(system_prompt, user_text),
|
| 168 |
+
"temperature": float(max(0.0, temperature)),
|
| 169 |
+
"max_tokens": int(max_tokens),
|
| 170 |
+
"stream": False,
|
| 171 |
+
}
|
| 172 |
+
if stop:
|
| 173 |
+
payload["stop"] = stop
|
| 174 |
+
if frequency_penalty is not None:
|
| 175 |
+
payload["frequency_penalty"] = float(frequency_penalty)
|
| 176 |
+
if presence_penalty is not None:
|
| 177 |
+
payload["presence_penalty"] = float(presence_penalty)
|
| 178 |
+
|
| 179 |
+
text, ok = self._try_once(payload)
|
| 180 |
+
if ok:
|
| 181 |
+
return text
|
| 182 |
+
|
| 183 |
+
# fallback (if configured)
|
| 184 |
+
if self.fallback:
|
| 185 |
+
payload["model"] = _model_with_provider(self.fallback, self.provider)
|
| 186 |
+
text, ok = self._try_once(payload)
|
| 187 |
+
if ok:
|
| 188 |
+
return text
|
| 189 |
+
|
| 190 |
+
raise RuntimeError(f"Chat non-stream failed: model={self.model} fallback={self.fallback}")
|
| 191 |
+
|
| 192 |
+
def _try_once(self, payload: dict) -> Tuple[str, bool]:
|
| 193 |
+
last_err: Optional[Exception] = None
|
| 194 |
+
for attempt in range(self.max_retries + 1):
|
| 195 |
+
try:
|
| 196 |
+
r = requests.post(ROUTER_URL, headers=self.headers, json=payload, timeout=self.timeout)
|
| 197 |
+
if r.status_code >= 400:
|
| 198 |
+
logger.error("Router error %s: %s", r.status_code, r.text)
|
| 199 |
+
last_err = RuntimeError(f"{r.status_code}: {r.text}")
|
| 200 |
+
time.sleep(min(1.5 * (attempt + 1), 3.0))
|
| 201 |
+
continue
|
| 202 |
+
data = r.json()
|
| 203 |
+
return data["choices"][0]["message"]["content"], True
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.error("Router request failure: %s", e)
|
| 206 |
+
last_err = e
|
| 207 |
+
time.sleep(min(1.5 * (attempt + 1), 3.0))
|
| 208 |
+
if last_err:
|
| 209 |
+
logger.error("Router exhausted retries: %s", last_err)
|
| 210 |
+
return "", False
|
| 211 |
+
|
| 212 |
+
# -------- Streaming (yield token deltas) --------
|
| 213 |
+
def chat_stream(
|
| 214 |
+
self,
|
| 215 |
+
system_prompt: Optional[str],
|
| 216 |
+
user_text: str,
|
| 217 |
+
max_tokens: int,
|
| 218 |
+
temperature: float,
|
| 219 |
+
stop: Optional[List[str]] = None,
|
| 220 |
+
frequency_penalty: Optional[float] = None,
|
| 221 |
+
presence_penalty: Optional[float] = None,
|
| 222 |
+
) -> Iterator[str]:
|
| 223 |
+
payload = {
|
| 224 |
+
"model": _model_with_provider(self.model, self.provider),
|
| 225 |
+
"messages": _mk_messages(system_prompt, user_text),
|
| 226 |
+
"temperature": float(max(0.0, temperature)),
|
| 227 |
+
"max_tokens": int(max_tokens),
|
| 228 |
+
"stream": True,
|
| 229 |
+
}
|
| 230 |
+
if stop:
|
| 231 |
+
payload["stop"] = stop
|
| 232 |
+
if frequency_penalty is not None:
|
| 233 |
+
payload["frequency_penalty"] = float(frequency_penalty)
|
| 234 |
+
if presence_penalty is not None:
|
| 235 |
+
payload["presence_penalty"] = float(presence_penalty)
|
| 236 |
+
|
| 237 |
+
# primary
|
| 238 |
+
ok = False
|
| 239 |
+
for token in self._stream_once(payload):
|
| 240 |
+
ok = True
|
| 241 |
+
yield token
|
| 242 |
+
if ok:
|
| 243 |
+
return
|
| 244 |
+
# fallback stream if primary produced nothing (or died immediately)
|
| 245 |
+
if self.fallback:
|
| 246 |
+
payload["model"] = _model_with_provider(self.fallback, self.provider)
|
| 247 |
+
for token in self._stream_once(payload):
|
| 248 |
+
yield token
|
| 249 |
+
|
| 250 |
+
def _stream_once(self, payload: dict) -> Iterator[str]:
|
| 251 |
+
try:
|
| 252 |
+
with requests.post(ROUTER_URL, headers=self.headers, json=payload, stream=True, timeout=self.timeout) as r:
|
| 253 |
+
if r.status_code >= 400:
|
| 254 |
+
logger.error("Router stream error %s: %s", r.status_code, r.text)
|
| 255 |
+
return
|
| 256 |
+
for line in r.iter_lines(decode_unicode=True):
|
| 257 |
+
if not line:
|
| 258 |
+
continue
|
| 259 |
+
if not line.startswith("data:"):
|
| 260 |
+
continue
|
| 261 |
+
data = line[len("data:"):].strip()
|
| 262 |
+
if data == "[DONE]":
|
| 263 |
+
return
|
| 264 |
+
try:
|
| 265 |
+
obj = json.loads(data)
|
| 266 |
+
delta = obj["choices"][0]["delta"].get("content", "")
|
| 267 |
+
if delta:
|
| 268 |
+
yield delta
|
| 269 |
+
except Exception as e:
|
| 270 |
+
logger.warning("Stream JSON parse issue: %s | line=%r", e, line)
|
| 271 |
+
continue
|
| 272 |
+
except Exception as e:
|
| 273 |
+
logger.error("Stream request failure: %s", e)
|
| 274 |
+
return
|
| 275 |
+
|
| 276 |
+
# -------- Planning (non-stream) --------
|
| 277 |
+
def plan_nonstream(self, system_prompt: str, user_text: str,
|
| 278 |
+
max_tokens: int, temperature: float) -> str:
|
| 279 |
+
return self.chat_nonstream(system_prompt, user_text, max_tokens, temperature)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
__all__ = [
|
| 283 |
+
"ChatClient",
|
| 284 |
+
"chat",
|
| 285 |
+
"get_client",
|
| 286 |
+
"RouterRequestsClient",
|
| 287 |
+
]
|
app/core/inference/providers.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/core/inference/providers.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Provider layer for multi-backend LLM chat with a production-ready cascade:
|
| 6 |
+
|
| 7 |
+
GROQ → Gemini → Hugging Face Inference Router (Zephyr → Mistral)
|
| 8 |
+
|
| 9 |
+
- Each provider implements a common .chat(...) interface that returns either:
|
| 10 |
+
* str (non-stream), or
|
| 11 |
+
* Generator[str, None, None] (streaming text chunks)
|
| 12 |
+
|
| 13 |
+
- MultiProviderChat orchestrates providers in a user-configurable order (Settings.provider_order)
|
| 14 |
+
and returns the first successful response.
|
| 15 |
+
|
| 16 |
+
- Robustness:
|
| 17 |
+
* .env + logging are loaded via app.bootstrap import side-effect
|
| 18 |
+
* Requests session has retries and timeouts
|
| 19 |
+
* Provider initialization gracefully skips when keys/SDKs are missing
|
| 20 |
+
* Streaming uses SSE for HF Router; Groq uses SDK streaming; Gemini yields one chunk
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from typing import Any, Dict, Generator, Iterable, List, Optional, Union
|
| 24 |
+
import json
|
| 25 |
+
import logging
|
| 26 |
+
import os
|
| 27 |
+
import time
|
| 28 |
+
|
| 29 |
+
# Ensure .env + logging configured even if imported directly
|
| 30 |
+
import app.bootstrap # noqa: F401
|
| 31 |
+
|
| 32 |
+
import requests
|
| 33 |
+
from requests.adapters import HTTPAdapter
|
| 34 |
+
from urllib3.util.retry import Retry
|
| 35 |
+
|
| 36 |
+
# Optional SDKs; handled gracefully if absent
|
| 37 |
+
try:
|
| 38 |
+
from groq import Groq
|
| 39 |
+
except Exception: # pragma: no cover
|
| 40 |
+
Groq = None # type: ignore
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
from google import genai
|
| 44 |
+
except Exception: # pragma: no cover
|
| 45 |
+
genai = None # type: ignore
|
| 46 |
+
|
| 47 |
+
from app.core.config import Settings
|
| 48 |
+
|
| 49 |
+
logger = logging.getLogger(__name__)
|
| 50 |
+
|
| 51 |
+
Message = Dict[str, str] # {"role": "system|user|assistant", "content": "..."}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ---------- Errors ----------
|
| 55 |
+
class ProviderError(RuntimeError):
|
| 56 |
+
"""Raised for provider-specific configuration/runtime errors."""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ---------- Helpers ----------
|
| 60 |
+
def _ensure_messages(msgs: Iterable[Message]) -> List[Message]:
|
| 61 |
+
"""
|
| 62 |
+
Normalize incoming messages to a strict [{"role": str, "content": str}, ...] list.
|
| 63 |
+
"""
|
| 64 |
+
out: List[Message] = []
|
| 65 |
+
for m in msgs:
|
| 66 |
+
role = m.get("role", "user")
|
| 67 |
+
content = m.get("content", "")
|
| 68 |
+
out.append({"role": role, "content": content})
|
| 69 |
+
return out
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _requests_session_with_retries(
|
| 73 |
+
total: int = 3,
|
| 74 |
+
backoff: float = 0.3,
|
| 75 |
+
status_forcelist: Optional[List[int]] = None,
|
| 76 |
+
timeout: float = 60.0,
|
| 77 |
+
) -> requests.Session:
|
| 78 |
+
"""
|
| 79 |
+
Return a requests.Session configured with retries, connection pooling, and default timeouts.
|
| 80 |
+
"""
|
| 81 |
+
status_forcelist = status_forcelist or [408, 429, 500, 502, 503, 504]
|
| 82 |
+
retry = Retry(
|
| 83 |
+
total=total,
|
| 84 |
+
read=total,
|
| 85 |
+
connect=total,
|
| 86 |
+
backoff_factor=backoff,
|
| 87 |
+
status_forcelist=status_forcelist,
|
| 88 |
+
allowed_methods=frozenset(["GET", "POST"]),
|
| 89 |
+
raise_on_status=False,
|
| 90 |
+
)
|
| 91 |
+
adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=10)
|
| 92 |
+
session = requests.Session()
|
| 93 |
+
session.mount("http://", adapter)
|
| 94 |
+
session.mount("https://", adapter)
|
| 95 |
+
# Store default timeout on session via a patched request method
|
| 96 |
+
session.request = _patch_request_with_timeout(session.request, timeout) # type: ignore
|
| 97 |
+
return session
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _patch_request_with_timeout(fn, timeout: float):
|
| 101 |
+
def wrapper(method, url, **kwargs):
|
| 102 |
+
if "timeout" not in kwargs:
|
| 103 |
+
kwargs["timeout"] = timeout
|
| 104 |
+
return fn(method, url, **kwargs)
|
| 105 |
+
|
| 106 |
+
return wrapper
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ---------- GROQ ----------
|
| 110 |
+
class GroqProvider:
|
| 111 |
+
"""
|
| 112 |
+
Groq Chat Completions (OpenAI-compatible).
|
| 113 |
+
Requires:
|
| 114 |
+
- env: GROQ_API_KEY
|
| 115 |
+
- package: groq
|
| 116 |
+
"""
|
| 117 |
+
name = "groq"
|
| 118 |
+
|
| 119 |
+
def __init__(self, model: str):
|
| 120 |
+
self.model = model
|
| 121 |
+
self.api_key = os.getenv("GROQ_API_KEY")
|
| 122 |
+
if not self.api_key:
|
| 123 |
+
raise ProviderError("GROQ_API_KEY is not set")
|
| 124 |
+
if Groq is None:
|
| 125 |
+
raise ProviderError("groq SDK not installed; add 'groq' to requirements.txt and pip install.")
|
| 126 |
+
# SDK reads key from env
|
| 127 |
+
self.client = Groq()
|
| 128 |
+
|
| 129 |
+
def chat(
|
| 130 |
+
self,
|
| 131 |
+
messages: Iterable[Message],
|
| 132 |
+
temperature: float,
|
| 133 |
+
max_new_tokens: int,
|
| 134 |
+
stream: bool,
|
| 135 |
+
) -> Union[str, Generator[str, None, None]]:
|
| 136 |
+
msgs = _ensure_messages(messages)
|
| 137 |
+
try:
|
| 138 |
+
completion = self.client.chat.completions.create(
|
| 139 |
+
model=self.model,
|
| 140 |
+
messages=msgs,
|
| 141 |
+
temperature=float(temperature),
|
| 142 |
+
max_tokens=int(max_new_tokens),
|
| 143 |
+
top_p=1,
|
| 144 |
+
stream=bool(stream),
|
| 145 |
+
)
|
| 146 |
+
if stream:
|
| 147 |
+
def gen():
|
| 148 |
+
for chunk in completion:
|
| 149 |
+
try:
|
| 150 |
+
delta = chunk.choices[0].delta
|
| 151 |
+
part = getattr(delta, "content", None)
|
| 152 |
+
if part:
|
| 153 |
+
yield part
|
| 154 |
+
except Exception:
|
| 155 |
+
continue
|
| 156 |
+
return gen()
|
| 157 |
+
else:
|
| 158 |
+
# Non-streaming: return final message content
|
| 159 |
+
return completion.choices[0].message.content or ""
|
| 160 |
+
except Exception as e:
|
| 161 |
+
raise ProviderError(f"GROQ error: {e}") from e
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ---------- GEMINI ----------
|
| 165 |
+
class GeminiProvider:
|
| 166 |
+
"""
|
| 167 |
+
Google Gemini via google-genai.
|
| 168 |
+
Requires:
|
| 169 |
+
- env: GOOGLE_API_KEY
|
| 170 |
+
- package: google-genai
|
| 171 |
+
|
| 172 |
+
Role mapping:
|
| 173 |
+
- system → system_instruction (joined)
|
| 174 |
+
- user → role 'user'
|
| 175 |
+
- assistant → role 'model'
|
| 176 |
+
"""
|
| 177 |
+
name = "gemini"
|
| 178 |
+
|
| 179 |
+
def __init__(self, model: str):
|
| 180 |
+
self.model = model
|
| 181 |
+
self.api_key = os.getenv("GOOGLE_API_KEY")
|
| 182 |
+
if not self.api_key:
|
| 183 |
+
raise ProviderError("GOOGLE_API_KEY is not set")
|
| 184 |
+
if genai is None:
|
| 185 |
+
raise ProviderError("google-genai SDK not installed; add 'google-genai' to requirements.txt and pip install.")
|
| 186 |
+
self.client = genai.Client(api_key=self.api_key)
|
| 187 |
+
|
| 188 |
+
@staticmethod
|
| 189 |
+
def _split_system_and_messages(msgs: List[Message]) -> tuple[str, List[dict]]:
|
| 190 |
+
system_parts: List[str] = []
|
| 191 |
+
contents: List[dict] = []
|
| 192 |
+
for m in msgs:
|
| 193 |
+
role = m.get("role", "user")
|
| 194 |
+
text = m.get("content", "")
|
| 195 |
+
if role == "system":
|
| 196 |
+
system_parts.append(text)
|
| 197 |
+
else:
|
| 198 |
+
mapped = "user" if role == "user" else "model"
|
| 199 |
+
contents.append({"role": mapped, "parts": [{"text": text}]})
|
| 200 |
+
return ("\n".join(system_parts).strip(), contents)
|
| 201 |
+
|
| 202 |
+
def chat(
|
| 203 |
+
self,
|
| 204 |
+
messages: Iterable[Message],
|
| 205 |
+
temperature: float,
|
| 206 |
+
max_new_tokens: int,
|
| 207 |
+
stream: bool,
|
| 208 |
+
) -> Union[str, Generator[str, None, None]]:
|
| 209 |
+
msgs = _ensure_messages(messages)
|
| 210 |
+
system_instruction, contents = self._split_system_and_messages(msgs)
|
| 211 |
+
try:
|
| 212 |
+
# Some versions of google-genai expose system_instruction; if not, we prepend.
|
| 213 |
+
kwargs: Dict[str, Any] = {
|
| 214 |
+
"model": self.model,
|
| 215 |
+
"contents": contents,
|
| 216 |
+
"generation_config": {
|
| 217 |
+
"temperature": float(temperature),
|
| 218 |
+
"max_output_tokens": int(max_new_tokens),
|
| 219 |
+
},
|
| 220 |
+
}
|
| 221 |
+
try:
|
| 222 |
+
resp = self.client.models.generate_content(system_instruction=system_instruction or None, **kwargs)
|
| 223 |
+
except TypeError:
|
| 224 |
+
# Fallback for older SDKs: inject system as first user turn
|
| 225 |
+
if system_instruction:
|
| 226 |
+
contents = [{"role": "user", "parts": [{"text": f"System: {system_instruction}"}]}] + contents
|
| 227 |
+
kwargs["contents"] = contents
|
| 228 |
+
resp = self.client.models.generate_content(**kwargs)
|
| 229 |
+
|
| 230 |
+
text = getattr(resp, "text", "") or ""
|
| 231 |
+
|
| 232 |
+
if stream:
|
| 233 |
+
# Fake streaming for API parity: one chunk
|
| 234 |
+
def gen():
|
| 235 |
+
yield text
|
| 236 |
+
return gen()
|
| 237 |
+
return text
|
| 238 |
+
except Exception as e:
|
| 239 |
+
raise ProviderError(f"Gemini error: {e}") from e
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ---------- HF Inference Router ----------
|
| 243 |
+
class HfRouterProvider:
|
| 244 |
+
"""
|
| 245 |
+
Hugging Face Inference Router (OpenAI-like /v1/chat/completions).
|
| 246 |
+
Tries primary -> fallback model (both can include optional provider tag, e.g., "model:featherless-ai").
|
| 247 |
+
|
| 248 |
+
Requires:
|
| 249 |
+
- env: HF_TOKEN
|
| 250 |
+
- package: requests
|
| 251 |
+
"""
|
| 252 |
+
name = "router"
|
| 253 |
+
BASE_URL = "https://router.huggingface.co/v1/chat/completions"
|
| 254 |
+
|
| 255 |
+
def __init__(self, primary_model: str, fallback_model: Optional[str], provider_tag: Optional[str]):
|
| 256 |
+
self.primary = primary_model
|
| 257 |
+
self.fallback = fallback_model
|
| 258 |
+
self.provider_tag = provider_tag
|
| 259 |
+
self.token = os.getenv("HF_TOKEN")
|
| 260 |
+
if not self.token:
|
| 261 |
+
raise ProviderError("HF_TOKEN is not set")
|
| 262 |
+
self.session = _requests_session_with_retries(total=3, backoff=0.5, timeout=60.0)
|
| 263 |
+
|
| 264 |
+
def _fmt_model(self, model: str) -> str:
|
| 265 |
+
return model if not self.provider_tag else f"{model}:{self.provider_tag}"
|
| 266 |
+
|
| 267 |
+
def _sse_stream(self, resp: requests.Response) -> Generator[str, None, None]:
|
| 268 |
+
for raw in resp.iter_lines(decode_unicode=True):
|
| 269 |
+
if not raw:
|
| 270 |
+
continue
|
| 271 |
+
if not raw.startswith("data:"):
|
| 272 |
+
continue
|
| 273 |
+
data = raw[5:].strip()
|
| 274 |
+
if data == "[DONE]":
|
| 275 |
+
break
|
| 276 |
+
try:
|
| 277 |
+
obj = json.loads(data)
|
| 278 |
+
except Exception:
|
| 279 |
+
continue
|
| 280 |
+
try:
|
| 281 |
+
delta = obj["choices"][0].get("delta", {})
|
| 282 |
+
content = delta.get("content")
|
| 283 |
+
if content:
|
| 284 |
+
yield content
|
| 285 |
+
except Exception:
|
| 286 |
+
continue
|
| 287 |
+
|
| 288 |
+
def _call_router(
|
| 289 |
+
self,
|
| 290 |
+
model: str,
|
| 291 |
+
messages: List[Message],
|
| 292 |
+
temperature: float,
|
| 293 |
+
max_new_tokens: int,
|
| 294 |
+
stream: bool,
|
| 295 |
+
) -> Union[str, Generator[str, None, None]]:
|
| 296 |
+
headers = {
|
| 297 |
+
"Authorization": f"Bearer {self.token}",
|
| 298 |
+
"Content-Type": "application/json",
|
| 299 |
+
}
|
| 300 |
+
payload: Dict[str, Any] = {
|
| 301 |
+
"model": self._fmt_model(model),
|
| 302 |
+
"messages": messages,
|
| 303 |
+
"temperature": float(temperature),
|
| 304 |
+
"max_tokens": int(max_new_tokens),
|
| 305 |
+
"stream": bool(stream),
|
| 306 |
+
}
|
| 307 |
+
if stream:
|
| 308 |
+
with self.session.post(self.BASE_URL, headers=headers, json=payload, stream=True) as r:
|
| 309 |
+
if r.status_code >= 400:
|
| 310 |
+
raise ProviderError(f"HF Router HTTP {r.status_code}: {r.text[:300]}")
|
| 311 |
+
return self._sse_stream(r)
|
| 312 |
+
else:
|
| 313 |
+
r = self.session.post(self.BASE_URL, headers=headers, json=payload)
|
| 314 |
+
if r.status_code >= 400:
|
| 315 |
+
raise ProviderError(f"HF Router HTTP {r.status_code}: {r.text[:300]}")
|
| 316 |
+
obj = r.json()
|
| 317 |
+
try:
|
| 318 |
+
return obj["choices"][0]["message"]["content"]
|
| 319 |
+
except Exception as e:
|
| 320 |
+
raise ProviderError(f"HF Router response parsing error: {e}") from e
|
| 321 |
+
|
| 322 |
+
def chat(
|
| 323 |
+
self,
|
| 324 |
+
messages: Iterable[Message],
|
| 325 |
+
temperature: float,
|
| 326 |
+
max_new_tokens: int,
|
| 327 |
+
stream: bool,
|
| 328 |
+
) -> Union[str, Generator[str, None, None]]:
|
| 329 |
+
msgs = _ensure_messages(messages)
|
| 330 |
+
try:
|
| 331 |
+
return self._call_router(self.primary, msgs, temperature, max_new_tokens, stream)
|
| 332 |
+
except Exception as e1:
|
| 333 |
+
logger.warning("HF primary model failed (%s): %s", self.primary, e1)
|
| 334 |
+
if self.fallback:
|
| 335 |
+
return self._call_router(self.fallback, msgs, temperature, max_new_tokens, stream)
|
| 336 |
+
raise
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# ---------- Orchestrator ----------
|
| 340 |
+
class MultiProviderChat:
|
| 341 |
+
"""
|
| 342 |
+
Tries providers in configured order. First success wins.
|
| 343 |
+
Skips misconfigured providers (missing key or SDK).
|
| 344 |
+
"""
|
| 345 |
+
def __init__(self, settings: Settings):
|
| 346 |
+
m = settings.model
|
| 347 |
+
order = [p.strip().lower() for p in settings.provider_order]
|
| 348 |
+
self.providers: List[Any] = []
|
| 349 |
+
|
| 350 |
+
for p in order:
|
| 351 |
+
try:
|
| 352 |
+
if p == "groq":
|
| 353 |
+
self.providers.append(GroqProvider(m.groq_model))
|
| 354 |
+
elif p == "gemini":
|
| 355 |
+
self.providers.append(GeminiProvider(m.gemini_model))
|
| 356 |
+
elif p == "router":
|
| 357 |
+
self.providers.append(HfRouterProvider(m.name, m.fallback, m.provider))
|
| 358 |
+
else:
|
| 359 |
+
logger.warning("Unknown provider '%s' in provider_order; skipping.", p)
|
| 360 |
+
except ProviderError as e:
|
| 361 |
+
logger.warning("Provider '%s' not available: %s (will skip)", p, e)
|
| 362 |
+
continue
|
| 363 |
+
|
| 364 |
+
if not self.providers:
|
| 365 |
+
raise ProviderError("No providers are configured/available")
|
| 366 |
+
|
| 367 |
+
self.temperature = m.temperature
|
| 368 |
+
self.max_new_tokens = m.max_new_tokens
|
| 369 |
+
|
| 370 |
+
def chat(
|
| 371 |
+
self,
|
| 372 |
+
messages: Iterable[Message],
|
| 373 |
+
temperature: Optional[float] = None,
|
| 374 |
+
max_new_tokens: Optional[int] = None,
|
| 375 |
+
stream: bool = True,
|
| 376 |
+
) -> Union[str, Generator[str, None, None]]:
|
| 377 |
+
temp = float(self.temperature if temperature is None else temperature)
|
| 378 |
+
mx = int(self.max_new_tokens if max_new_tokens is None else max_new_tokens)
|
| 379 |
+
last_err: Optional[Exception] = None
|
| 380 |
+
|
| 381 |
+
for provider in self.providers:
|
| 382 |
+
pname = getattr(provider, "name", provider.__class__.__name__)
|
| 383 |
+
t0 = time.time()
|
| 384 |
+
try:
|
| 385 |
+
result = provider.chat(messages, temp, mx, stream)
|
| 386 |
+
logger.info("Provider '%s' succeeded in %.2fs", pname, time.time() - t0)
|
| 387 |
+
return result
|
| 388 |
+
except Exception as e:
|
| 389 |
+
logger.warning("Provider '%s' failed: %s", pname, e)
|
| 390 |
+
last_err = e
|
| 391 |
+
continue
|
| 392 |
+
|
| 393 |
+
raise ProviderError(f"All providers failed. Last error: {last_err}")
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
__all__ = [
|
| 397 |
+
"ProviderError",
|
| 398 |
+
"GroqProvider",
|
| 399 |
+
"GeminiProvider",
|
| 400 |
+
"HfRouterProvider",
|
| 401 |
+
"MultiProviderChat",
|
| 402 |
+
]
|
app/core/logging.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/core/logging.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
import uuid
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
_DEF_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
| 10 |
+
_DEF_DATEFMT = "%Y-%m-%dT%H:%M:%S%z"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def setup_logging(level: Optional[str] = None) -> None:
|
| 14 |
+
"""
|
| 15 |
+
Idempotent logging setup.
|
| 16 |
+
- Honors LOG_LEVEL env (default INFO) unless an explicit level is passed.
|
| 17 |
+
- Avoids duplicate handlers if called multiple times.
|
| 18 |
+
- Tames noisy third-party loggers by default.
|
| 19 |
+
"""
|
| 20 |
+
root = logging.getLogger()
|
| 21 |
+
if root.handlers:
|
| 22 |
+
return # already configured
|
| 23 |
+
|
| 24 |
+
log_level = (level or os.getenv("LOG_LEVEL", "INFO")).upper()
|
| 25 |
+
try:
|
| 26 |
+
parsed_level = getattr(logging, log_level)
|
| 27 |
+
except AttributeError:
|
| 28 |
+
parsed_level = logging.INFO
|
| 29 |
+
|
| 30 |
+
handler = logging.StreamHandler()
|
| 31 |
+
formatter = logging.Formatter(_DEF_FORMAT, datefmt=_DEF_DATEFMT)
|
| 32 |
+
handler.setFormatter(formatter)
|
| 33 |
+
|
| 34 |
+
root.setLevel(parsed_level)
|
| 35 |
+
root.addHandler(handler)
|
| 36 |
+
|
| 37 |
+
# Quiet noisy libs by default; adjust if you need more/less detail.
|
| 38 |
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
| 39 |
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
| 40 |
+
logging.getLogger("requests").setLevel(logging.WARNING)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def add_trace_id(request) -> None:
|
| 44 |
+
"""
|
| 45 |
+
Injects a unique `trace_id` into request.state (works with FastAPI-style objects).
|
| 46 |
+
Duck-typed to avoid importing FastAPI here.
|
| 47 |
+
"""
|
| 48 |
+
try:
|
| 49 |
+
state = getattr(request, "state", None)
|
| 50 |
+
if state is None:
|
| 51 |
+
# Some frameworks may not have .state; just skip silently.
|
| 52 |
+
return
|
| 53 |
+
if not hasattr(state, "trace_id"):
|
| 54 |
+
state.trace_id = str(uuid.uuid4())
|
| 55 |
+
except Exception:
|
| 56 |
+
# Never let logging helpers break the app.
|
| 57 |
+
return
|
app/core/prompts/__init__.py
ADDED
|
File without changes
|
app/core/prompts/plan.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are MATRIX-AI Planner.
|
| 2 |
+
|
| 3 |
+
Return ONLY a single JSON object. Do not include backticks, code fences, Markdown, or any prose.
|
| 4 |
+
The JSON MUST match this schema exactly:
|
| 5 |
+
|
| 6 |
+
{
|
| 7 |
+
"plan_id": "<string>",
|
| 8 |
+
"steps": ["<string>", "..."],
|
| 9 |
+
"risk": "low" | "medium" | "high",
|
| 10 |
+
"explanation": "<string>"
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
Rules:
|
| 14 |
+
- Keep steps short, safe, and auditable (1–3 steps).
|
| 15 |
+
- Prefer low risk actions.
|
| 16 |
+
- Do not add any extra keys.
|
| 17 |
+
- Start your reply with '{' and end with '}'.
|
app/core/rag/__init__.py
ADDED
|
File without changes
|
app/core/rag/build.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import json, os, re, time, math, logging
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Dict, List, Iterable, Tuple, Optional
|
| 5 |
+
|
| 6 |
+
import yaml
|
| 7 |
+
import requests
|
| 8 |
+
|
| 9 |
+
log = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
# -------------------------
|
| 12 |
+
# Text cleaning & chunking
|
| 13 |
+
# -------------------------
|
| 14 |
+
|
| 15 |
+
_MD_FRONTMATTER = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
|
| 16 |
+
|
| 17 |
+
def normalize_text(text: str) -> str:
|
| 18 |
+
lines = [ln.strip() for ln in text.splitlines()]
|
| 19 |
+
cleaned = []
|
| 20 |
+
for ln in lines:
|
| 21 |
+
if not ln:
|
| 22 |
+
continue
|
| 23 |
+
if sum(ch.isalnum() for ch in ln) < 3:
|
| 24 |
+
continue
|
| 25 |
+
cleaned.append(ln)
|
| 26 |
+
s = "\n".join(cleaned)
|
| 27 |
+
s = re.sub(r"\n{3,}", "\n\n", s)
|
| 28 |
+
return s.strip()
|
| 29 |
+
|
| 30 |
+
def md_to_text(md: str) -> str:
|
| 31 |
+
md = re.sub(_MD_FRONTMATTER, "", md)
|
| 32 |
+
md = re.sub(r"```.*?```", "", md, flags=re.DOTALL) # drop fenced code
|
| 33 |
+
md = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", md) # drop images
|
| 34 |
+
md = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", md) # links -> label
|
| 35 |
+
md = re.sub(r"^\s{0,3}#{1,6}\s*", "", md, flags=re.MULTILINE)
|
| 36 |
+
md = md.replace("`", "")
|
| 37 |
+
md = re.sub(r"^\s*[-*+]\s+", "• ", md, flags=re.MULTILINE)
|
| 38 |
+
md = re.sub(r"^\s*>\s?", "", md, flags=re.MULTILINE)
|
| 39 |
+
return normalize_text(md)
|
| 40 |
+
|
| 41 |
+
def chunk_text(text: str, max_chars: int = 800, overlap: int = 120) -> List[str]:
|
| 42 |
+
paras = [p.strip() for p in text.split("\n\n") if p.strip()]
|
| 43 |
+
out: List[str] = []
|
| 44 |
+
buf = ""
|
| 45 |
+
for p in paras:
|
| 46 |
+
if len(p) > max_chars:
|
| 47 |
+
i = 0
|
| 48 |
+
while i < len(p):
|
| 49 |
+
j = min(i + max_chars, len(p))
|
| 50 |
+
out.append(p[i:j])
|
| 51 |
+
i = j - overlap if j - overlap > i else j
|
| 52 |
+
continue
|
| 53 |
+
if len(buf) + 2 + len(p) <= max_chars:
|
| 54 |
+
buf = (buf + "\n\n" + p) if buf else p
|
| 55 |
+
else:
|
| 56 |
+
if buf:
|
| 57 |
+
out.append(buf)
|
| 58 |
+
buf = p
|
| 59 |
+
if buf:
|
| 60 |
+
out.append(buf)
|
| 61 |
+
return out
|
| 62 |
+
|
| 63 |
+
def write_jsonl(records: Iterable[Dict], out_path: Path) -> None:
|
| 64 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 65 |
+
with out_path.open("w", encoding="utf-8") as f:
|
| 66 |
+
for rec in records:
|
| 67 |
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
| 68 |
+
|
| 69 |
+
# -------------------------
|
| 70 |
+
# GitHub API helpers
|
| 71 |
+
# -------------------------
|
| 72 |
+
|
| 73 |
+
def gh_session() -> requests.Session:
|
| 74 |
+
s = requests.Session()
|
| 75 |
+
s.headers.update({
|
| 76 |
+
"Accept": "application/vnd.github+json",
|
| 77 |
+
"User-Agent": "matrix-ai-rag-builder/1.0",
|
| 78 |
+
})
|
| 79 |
+
tok = os.getenv("GITHUB_TOKEN")
|
| 80 |
+
if tok:
|
| 81 |
+
s.headers["Authorization"] = f"Bearer {tok}"
|
| 82 |
+
return s
|
| 83 |
+
|
| 84 |
+
def gh_get_json(url: str, sess: requests.Session, max_retries: int = 3) -> Dict | List:
|
| 85 |
+
backoff = 1.0
|
| 86 |
+
for attempt in range(max_retries):
|
| 87 |
+
r = sess.get(url, timeout=25)
|
| 88 |
+
if r.status_code == 403 and "rate limit" in r.text.lower():
|
| 89 |
+
log.warning("GitHub rate-limited; sleeping %.1fs", backoff)
|
| 90 |
+
time.sleep(backoff)
|
| 91 |
+
backoff = min(backoff * 2, 30)
|
| 92 |
+
continue
|
| 93 |
+
r.raise_for_status()
|
| 94 |
+
return r.json()
|
| 95 |
+
r.raise_for_status()
|
| 96 |
+
return {}
|
| 97 |
+
|
| 98 |
+
def gh_list_org_repos(org: str, sess: requests.Session) -> List[Dict]:
|
| 99 |
+
repos: List[Dict] = []
|
| 100 |
+
page = 1
|
| 101 |
+
while True:
|
| 102 |
+
url = f"https://api.github.com/orgs/{org}/repos?per_page=100&page={page}"
|
| 103 |
+
js = gh_get_json(url, sess)
|
| 104 |
+
if not js:
|
| 105 |
+
break
|
| 106 |
+
repos.extend(js)
|
| 107 |
+
if len(js) < 100:
|
| 108 |
+
break
|
| 109 |
+
page += 1
|
| 110 |
+
return repos
|
| 111 |
+
|
| 112 |
+
def gh_list_tree(owner: str, repo: str, branch: str, sess: requests.Session) -> List[Dict]:
|
| 113 |
+
url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1"
|
| 114 |
+
js = gh_get_json(url, sess)
|
| 115 |
+
return js.get("tree", []) if isinstance(js, dict) else []
|
| 116 |
+
|
| 117 |
+
def gh_fetch_raw(owner: str, repo: str, branch: str, path: str, sess: requests.Session) -> Optional[str]:
|
| 118 |
+
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}"
|
| 119 |
+
r = sess.get(raw_url, timeout=25)
|
| 120 |
+
if r.status_code == 404 and branch == "main": # try master fallback
|
| 121 |
+
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/master/{path}"
|
| 122 |
+
r = sess.get(raw_url, timeout=25)
|
| 123 |
+
if r.status_code == 200:
|
| 124 |
+
return r.text
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
# -------------------------
|
| 128 |
+
# Builders
|
| 129 |
+
# -------------------------
|
| 130 |
+
|
| 131 |
+
def ingest_github_repo(owner: str, name: str, branch: str, docs_paths: List[str],
|
| 132 |
+
include_readme: bool, exts: Tuple[str,...] = (".md",".mdx",".txt")) -> List[Tuple[str,str]]:
|
| 133 |
+
sess = gh_session()
|
| 134 |
+
out: List[Tuple[str,str]] = []
|
| 135 |
+
|
| 136 |
+
# README
|
| 137 |
+
if include_readme:
|
| 138 |
+
for candidate in ("README.md", "readme.md", "README.MD"):
|
| 139 |
+
t = gh_fetch_raw(owner, name, branch, candidate, sess)
|
| 140 |
+
if t:
|
| 141 |
+
out.append((f"github:{owner}/{name}/{candidate}", md_to_text(t)))
|
| 142 |
+
break
|
| 143 |
+
|
| 144 |
+
# Tree -> docs paths
|
| 145 |
+
tree = gh_list_tree(owner, name, branch, sess)
|
| 146 |
+
if not tree:
|
| 147 |
+
return out
|
| 148 |
+
|
| 149 |
+
wanted_dirs = [p.strip("/").lower() for p in docs_paths]
|
| 150 |
+
for entry in tree:
|
| 151 |
+
if entry.get("type") != "blob":
|
| 152 |
+
continue
|
| 153 |
+
path = entry.get("path", "")
|
| 154 |
+
lower = path.lower()
|
| 155 |
+
if not lower.endswith(exts):
|
| 156 |
+
continue
|
| 157 |
+
if any(lower.startswith(d + "/") for d in wanted_dirs):
|
| 158 |
+
t = gh_fetch_raw(owner, name, branch, path, sess)
|
| 159 |
+
if not t:
|
| 160 |
+
continue
|
| 161 |
+
txt = md_to_text(t) if lower.endswith((".md",".mdx")) else normalize_text(t)
|
| 162 |
+
if txt:
|
| 163 |
+
out.append((f"github:{owner}/{name}/{path}", txt))
|
| 164 |
+
return out
|
| 165 |
+
|
| 166 |
+
def ingest_github_sources(cfg: Dict) -> List[Tuple[str,str]]:
|
| 167 |
+
out: List[Tuple[str,str]] = []
|
| 168 |
+
gh = cfg.get("github") or {}
|
| 169 |
+
sess = gh_session()
|
| 170 |
+
|
| 171 |
+
# explicit repos
|
| 172 |
+
for repo in (gh.get("repos") or []):
|
| 173 |
+
owner = repo["owner"]
|
| 174 |
+
name = repo["name"]
|
| 175 |
+
branch = repo.get("branch", "main")
|
| 176 |
+
docs_paths = repo.get("docs_paths", ["docs"])
|
| 177 |
+
include_readme = bool(repo.get("include_readme", True))
|
| 178 |
+
out.extend(ingest_github_repo(owner, name, branch, docs_paths, include_readme))
|
| 179 |
+
|
| 180 |
+
# whole org scan (README + docs/)
|
| 181 |
+
for org in (gh.get("orgs") or []):
|
| 182 |
+
try:
|
| 183 |
+
repos = gh_list_org_repos(org, sess)
|
| 184 |
+
except Exception as e:
|
| 185 |
+
log.warning("Failed to list org %s: %s", org, e)
|
| 186 |
+
continue
|
| 187 |
+
for r in repos:
|
| 188 |
+
owner = r["owner"]["login"]
|
| 189 |
+
name = r["name"]
|
| 190 |
+
default_branch = r.get("default_branch", "main")
|
| 191 |
+
# README + docs/
|
| 192 |
+
out.extend(ingest_github_repo(owner, name, default_branch, ["docs"], include_readme=True))
|
| 193 |
+
return out
|
| 194 |
+
|
| 195 |
+
def ingest_local_sources(cfg: Dict) -> List[Tuple[str,str]]:
|
| 196 |
+
out: List[Tuple[str,str]] = []
|
| 197 |
+
local = cfg.get("local") or {}
|
| 198 |
+
paths = local.get("paths") or []
|
| 199 |
+
glob_pat = local.get("glob", "**/*.md")
|
| 200 |
+
for p in paths:
|
| 201 |
+
fp = Path(p)
|
| 202 |
+
if fp.is_file():
|
| 203 |
+
try:
|
| 204 |
+
raw = fp.read_text(encoding="utf-8", errors="ignore")
|
| 205 |
+
txt = md_to_text(raw) if fp.suffix.lower() in {".md",".mdx"} else normalize_text(raw)
|
| 206 |
+
if txt:
|
| 207 |
+
out.append((str(fp), txt))
|
| 208 |
+
except Exception as e:
|
| 209 |
+
log.warning("Failed reading %s: %s", fp, e)
|
| 210 |
+
elif fp.is_dir():
|
| 211 |
+
for f in fp.rglob(glob_pat):
|
| 212 |
+
try:
|
| 213 |
+
raw = f.read_text(encoding="utf-8", errors="ignore")
|
| 214 |
+
txt = md_to_text(raw) if f.suffix.lower() in {".md",".mdx"} else normalize_text(raw)
|
| 215 |
+
if txt:
|
| 216 |
+
out.append((str(f), txt))
|
| 217 |
+
except Exception as e:
|
| 218 |
+
log.warning("Failed reading %s: %s", f, e)
|
| 219 |
+
return out
|
| 220 |
+
|
| 221 |
+
def build_kb_from_config(config_path: str = "configs/rag_sources.yaml",
|
| 222 |
+
out_jsonl: str = "data/kb.jsonl",
|
| 223 |
+
max_chars: int = 800,
|
| 224 |
+
overlap: int = 120,
|
| 225 |
+
minlen: int = 200,
|
| 226 |
+
dedupe: bool = True) -> int:
|
| 227 |
+
cfg: Dict = {}
|
| 228 |
+
p = Path(config_path)
|
| 229 |
+
if p.exists():
|
| 230 |
+
cfg = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
| 231 |
+
else:
|
| 232 |
+
log.warning("rag_sources.yaml not found at %s (using defaults)", p)
|
| 233 |
+
|
| 234 |
+
records: List[Dict] = []
|
| 235 |
+
|
| 236 |
+
# GitHub
|
| 237 |
+
try:
|
| 238 |
+
gh_docs = ingest_github_sources(cfg)
|
| 239 |
+
for src, text in gh_docs:
|
| 240 |
+
for chunk in chunk_text(text, max_chars, overlap):
|
| 241 |
+
if len(chunk) >= minlen:
|
| 242 |
+
records.append({"text": chunk, "source": src})
|
| 243 |
+
except Exception as e:
|
| 244 |
+
log.warning("GitHub ingest failed: %s", e)
|
| 245 |
+
|
| 246 |
+
# Local
|
| 247 |
+
try:
|
| 248 |
+
loc_docs = ingest_local_sources(cfg)
|
| 249 |
+
for src, text in loc_docs:
|
| 250 |
+
for chunk in chunk_text(text, max_chars, overlap):
|
| 251 |
+
if len(chunk) >= minlen:
|
| 252 |
+
records.append({"text": chunk, "source": src})
|
| 253 |
+
except Exception as e:
|
| 254 |
+
log.warning("Local ingest failed: %s", e)
|
| 255 |
+
|
| 256 |
+
# URLs (optional)
|
| 257 |
+
for url in (cfg.get("urls") or []):
|
| 258 |
+
try:
|
| 259 |
+
r = requests.get(url, timeout=25)
|
| 260 |
+
r.raise_for_status()
|
| 261 |
+
txt = normalize_text(r.text)
|
| 262 |
+
for chunk in chunk_text(txt, max_chars, overlap):
|
| 263 |
+
if len(chunk) >= minlen:
|
| 264 |
+
records.append({"text": chunk, "source": url})
|
| 265 |
+
except Exception as e:
|
| 266 |
+
log.warning("URL ingest failed for %s: %s", url, e)
|
| 267 |
+
|
| 268 |
+
if dedupe:
|
| 269 |
+
seen = set()
|
| 270 |
+
deduped: List[Dict] = []
|
| 271 |
+
for rec in records:
|
| 272 |
+
h = hash(rec["text"])
|
| 273 |
+
if h in seen:
|
| 274 |
+
continue
|
| 275 |
+
seen.add(h)
|
| 276 |
+
deduped.append(rec)
|
| 277 |
+
records = deduped
|
| 278 |
+
|
| 279 |
+
if not records:
|
| 280 |
+
log.warning("No KB records produced.")
|
| 281 |
+
return 0
|
| 282 |
+
|
| 283 |
+
out_path = Path(out_jsonl)
|
| 284 |
+
write_jsonl(records, out_path)
|
| 285 |
+
log.info("Wrote %d chunks to %s", len(records), out_path)
|
| 286 |
+
return len(records)
|
| 287 |
+
|
| 288 |
+
def ensure_kb(out_jsonl: str = "data/kb.jsonl",
|
| 289 |
+
config_path: str = "configs/rag_sources.yaml",
|
| 290 |
+
skip_if_exists: bool = True) -> bool:
|
| 291 |
+
"""
|
| 292 |
+
If kb.jsonl exists -> return True.
|
| 293 |
+
Else -> build from config and return True on success.
|
| 294 |
+
"""
|
| 295 |
+
out = Path(out_jsonl)
|
| 296 |
+
if skip_if_exists and out.exists() and out.stat().st_size > 0:
|
| 297 |
+
log.info("KB already present at %s (skipping build)", out)
|
| 298 |
+
return True
|
| 299 |
+
n = build_kb_from_config(config_path=config_path, out_jsonl=out_jsonl)
|
| 300 |
+
return n > 0
|
app/core/rag/retriever.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/core/rag/retriever.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import json, logging, os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import List, Dict, Optional
|
| 6 |
+
import numpy as np
|
| 7 |
+
import faiss
|
| 8 |
+
from sentence_transformers import SentenceTransformer
|
| 9 |
+
|
| 10 |
+
log = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
class Retriever:
|
| 13 |
+
def __init__(self, kb_path: str = "data/kb.jsonl",
|
| 14 |
+
model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
|
| 15 |
+
top_k: int = 4):
|
| 16 |
+
self.kb_path = Path(kb_path)
|
| 17 |
+
self.top_k = top_k
|
| 18 |
+
if not self.kb_path.exists():
|
| 19 |
+
raise FileNotFoundError(f"KB file not found: {self.kb_path} (jsonl with {{text,source}})")
|
| 20 |
+
|
| 21 |
+
# Use a project-local cache to avoid '/.cache' permission issues
|
| 22 |
+
cache_dir = Path(os.getenv("HF_HOME", "./.cache"))
|
| 23 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
|
| 25 |
+
self.model = SentenceTransformer(model_name, cache_folder=str(cache_dir))
|
| 26 |
+
|
| 27 |
+
self.docs: List[Dict[str, str]] = []
|
| 28 |
+
with self.kb_path.open("r", encoding="utf-8") as f:
|
| 29 |
+
for line in f:
|
| 30 |
+
line = line.strip()
|
| 31 |
+
if not line:
|
| 32 |
+
continue
|
| 33 |
+
self.docs.append(json.loads(line))
|
| 34 |
+
texts = [d["text"] for d in self.docs]
|
| 35 |
+
emb = self.model.encode(texts, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False)
|
| 36 |
+
self.dim = int(emb.shape[1])
|
| 37 |
+
self.index = faiss.IndexFlatIP(self.dim)
|
| 38 |
+
self.index.add(emb.astype("float32"))
|
| 39 |
+
|
| 40 |
+
def retrieve(self, query: str, k: Optional[int] = None) -> List[Dict]:
|
| 41 |
+
k = k or self.top_k
|
| 42 |
+
vec = self.model.encode([query], convert_to_numpy=True, normalize_embeddings=True)
|
| 43 |
+
D, I = self.index.search(vec.astype("float32"), k)
|
| 44 |
+
out: List[Dict] = []
|
| 45 |
+
for idx, score in zip(I[0], D[0]):
|
| 46 |
+
if int(idx) < 0:
|
| 47 |
+
continue
|
| 48 |
+
d = self.docs[int(idx)]
|
| 49 |
+
out.append({"text": d["text"], "source": d.get("source", f"kb:{idx}"), "score": float(score)})
|
| 50 |
+
return out
|
app/core/rate_limit.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from collections import defaultdict
|
| 3 |
+
|
| 4 |
+
class RateLimiter:
|
| 5 |
+
def __init__(self):
|
| 6 |
+
self.windows: dict[str, tuple[int, int]] = defaultdict(lambda: (0, 0))
|
| 7 |
+
|
| 8 |
+
def allow(self, ip: str, route: str, per_minute: int) -> bool:
|
| 9 |
+
"""Checks if a request is allowed under a fixed-window rate limit."""
|
| 10 |
+
now = int(time.time())
|
| 11 |
+
current_window = now // 60
|
| 12 |
+
key = f"{ip}:{route}" # Simplified key for per-route limit
|
| 13 |
+
|
| 14 |
+
window_start, count = self.windows.get(key, (0, 0))
|
| 15 |
+
|
| 16 |
+
if window_start != current_window:
|
| 17 |
+
# New window, reset count
|
| 18 |
+
self.windows[key] = (current_window, 1)
|
| 19 |
+
return True
|
| 20 |
+
|
| 21 |
+
if count >= per_minute:
|
| 22 |
+
# Exceeded limit
|
| 23 |
+
return False
|
| 24 |
+
|
| 25 |
+
# Increment count in current window
|
| 26 |
+
self.windows[key] = (current_window, count + 1)
|
| 27 |
+
return True
|
app/core/redact.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
SECRET_PATTERN = re.compile(r"(bearer\s+[a-z0-9\-.~+/]+=*|sk-[a-z0-9]{20,})", re.IGNORECASE)
|
| 4 |
+
EMAIL_PATTERN = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", re.IGNORECASE)
|
| 5 |
+
|
| 6 |
+
def redact(text: str) -> str:
|
| 7 |
+
"""Redacts sensitive information like API keys and emails from a string."""
|
| 8 |
+
text = SECRET_PATTERN.sub("[REDACTED_TOKEN]", text)
|
| 9 |
+
text = EMAIL_PATTERN.sub("[REDACTED_EMAIL]", text)
|
| 10 |
+
return text
|
app/core/schema.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Optional, List, Literal
|
| 4 |
+
from pydantic import BaseModel, Field, ConfigDict
|
| 5 |
+
|
| 6 |
+
# ---------------------------
|
| 7 |
+
# Planning schema
|
| 8 |
+
# ---------------------------
|
| 9 |
+
|
| 10 |
+
class Health(BaseModel):
|
| 11 |
+
score: Optional[float] = None
|
| 12 |
+
status: Optional[str] = None
|
| 13 |
+
last_checked: Optional[str] = None # or use datetime if preferred
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class RecentCheck(BaseModel):
|
| 17 |
+
check: str
|
| 18 |
+
result: str
|
| 19 |
+
latency_ms: Optional[float] = None
|
| 20 |
+
ts: Optional[str] = None # or use datetime if preferred
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class PlanContext(BaseModel):
|
| 24 |
+
"""
|
| 25 |
+
Context is permissive: accept any extra keys from Guardian (or future sources).
|
| 26 |
+
Known fields are typed below; unknown fields pass through.
|
| 27 |
+
"""
|
| 28 |
+
model_config = ConfigDict(extra="allow")
|
| 29 |
+
|
| 30 |
+
# Common identifiers
|
| 31 |
+
app_id: Optional[str] = None
|
| 32 |
+
entity_uid: Optional[str] = None
|
| 33 |
+
|
| 34 |
+
# Known structured bits
|
| 35 |
+
symptoms: Optional[List[str]] = None
|
| 36 |
+
lkg: Optional[str] = None
|
| 37 |
+
lkg_version: Optional[str] = None
|
| 38 |
+
health: Optional[Health] = None
|
| 39 |
+
recent_checks: Optional[List[RecentCheck]] = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class PlanConstraints(BaseModel):
|
| 43 |
+
max_steps: int = Field(default=3, ge=1, le=10)
|
| 44 |
+
risk: Literal["low", "medium", "high"] = "low"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class PlanRequest(BaseModel):
|
| 48 |
+
# default to "plan" and only allow that value for now
|
| 49 |
+
mode: Literal["plan"] = "plan"
|
| 50 |
+
context: PlanContext
|
| 51 |
+
constraints: PlanConstraints = Field(default_factory=PlanConstraints)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class PlanResponse(BaseModel):
|
| 55 |
+
plan_id: str
|
| 56 |
+
steps: List[str]
|
| 57 |
+
risk: str
|
| 58 |
+
explanation: str
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ---------------------------
|
| 62 |
+
# Chat (kept for compatibility; router uses its own flexible model)
|
| 63 |
+
# ---------------------------
|
| 64 |
+
|
| 65 |
+
class ChatRequest(BaseModel):
|
| 66 |
+
question: str = Field(..., min_length=3, max_length=512)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class ChatResponse(BaseModel):
|
| 70 |
+
answer: str
|
| 71 |
+
sources: List[str] = Field(default_factory=list)
|
app/deps.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
from .core.config import Settings
|
| 3 |
+
|
| 4 |
+
@lru_cache(maxsize=1)
|
| 5 |
+
def get_settings() -> Settings:
|
| 6 |
+
"""FastAPI dependency to get application settings."""
|
| 7 |
+
return Settings.load()
|
app/main.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/main.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI, APIRouter, Request
|
| 11 |
+
from fastapi.responses import RedirectResponse, HTMLResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from fastapi.templating import Jinja2Templates
|
| 14 |
+
|
| 15 |
+
# ---- Early env load (HF_TOKEN, ADMIN_TOKEN, GITHUB_TOKEN, etc.) ----
|
| 16 |
+
def _load_env_file(paths: list[str]) -> None:
|
| 17 |
+
logger = logging.getLogger("uvicorn.error")
|
| 18 |
+
try:
|
| 19 |
+
from dotenv import load_dotenv # type: ignore
|
| 20 |
+
for p in paths:
|
| 21 |
+
if os.path.exists(p):
|
| 22 |
+
load_dotenv(dotenv_path=p, override=False)
|
| 23 |
+
logger.info("Loaded environment from %s", p)
|
| 24 |
+
return
|
| 25 |
+
logger.info("No .env file found in %s (skipping)", paths)
|
| 26 |
+
except Exception:
|
| 27 |
+
# Fallback, very small .env parser
|
| 28 |
+
for p in paths:
|
| 29 |
+
if not os.path.exists(p):
|
| 30 |
+
continue
|
| 31 |
+
try:
|
| 32 |
+
with open(p, "r", encoding="utf-8") as f:
|
| 33 |
+
for raw in f:
|
| 34 |
+
line = raw.strip()
|
| 35 |
+
if not line or line.startswith("#"):
|
| 36 |
+
continue
|
| 37 |
+
if line.startswith("export "):
|
| 38 |
+
line = line[len("export "):].strip()
|
| 39 |
+
if "=" not in line:
|
| 40 |
+
continue
|
| 41 |
+
key, val = line.split("=", 1)
|
| 42 |
+
key, val = key.strip(), val.strip()
|
| 43 |
+
if (val.startswith('"') and val.endswith('"')) or (
|
| 44 |
+
val.startswith("'") and val.endswith("'")
|
| 45 |
+
):
|
| 46 |
+
val = val[1:-1]
|
| 47 |
+
os.environ.setdefault(key, val)
|
| 48 |
+
logger.info("Loaded environment from %s (fallback parser)", p)
|
| 49 |
+
return
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.warning("Failed loading env from %s: %s", p, e)
|
| 52 |
+
logger.info("No .env loaded (none found / parsers failed)")
|
| 53 |
+
|
| 54 |
+
_load_env_file([".env", "configs/.env", ".env.local", "configs/.env.local"])
|
| 55 |
+
|
| 56 |
+
# ---- RAG DISABLED (commented out while debugging) ----
|
| 57 |
+
# from .deps import get_settings
|
| 58 |
+
# from .services.chat_service import get_retriever
|
| 59 |
+
# from .core.rag.build import ensure_kb
|
| 60 |
+
|
| 61 |
+
# ---- Middlewares ----
|
| 62 |
+
try:
|
| 63 |
+
from .middleware import attach_middlewares # type: ignore
|
| 64 |
+
except Exception:
|
| 65 |
+
try:
|
| 66 |
+
from .middlewares import attach_middlewares # type: ignore
|
| 67 |
+
except Exception:
|
| 68 |
+
def attach_middlewares(app: FastAPI) -> None:
|
| 69 |
+
logging.getLogger("uvicorn.error").warning(
|
| 70 |
+
"attach_middlewares not found; continuing without custom middlewares."
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# ---- Routers enabled ----
|
| 74 |
+
from .routers import health
|
| 75 |
+
from .ui import router as ui_router # <-- mount UI so /home works
|
| 76 |
+
|
| 77 |
+
# ---- Validator service integration ----
|
| 78 |
+
VALIDATOR_TAG = {"name": "Validator", "description": "A2A Validator UI and endpoints (/validator)."}
|
| 79 |
+
|
| 80 |
+
HAS_VALIDATOR = False
|
| 81 |
+
HAS_SOCKETIO = False
|
| 82 |
+
socketio_app = None # type: ignore[assignment]
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
# Primary validator router + optional Socket.IO app
|
| 86 |
+
from .services.validator_service import router as validator_router # type: ignore
|
| 87 |
+
HAS_VALIDATOR = True
|
| 88 |
+
try:
|
| 89 |
+
from .services.validator_service import socketio_app as _socketio_app # type: ignore
|
| 90 |
+
socketio_app = _socketio_app
|
| 91 |
+
HAS_SOCKETIO = socketio_app is not None
|
| 92 |
+
except Exception:
|
| 93 |
+
socketio_app = None
|
| 94 |
+
HAS_SOCKETIO = False
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logging.getLogger("uvicorn.error").warning("validator_service import failed: %s", e)
|
| 97 |
+
# Fallback validator router if import fails
|
| 98 |
+
_templates = Jinja2Templates(directory="app/templates")
|
| 99 |
+
validator_router = APIRouter(prefix="/validator", tags=["Validator"])
|
| 100 |
+
|
| 101 |
+
@validator_router.get("", response_class=HTMLResponse)
|
| 102 |
+
@validator_router.get("/", response_class=HTMLResponse)
|
| 103 |
+
async def _validator_fallback_ui(request: Request) -> HTMLResponse:
|
| 104 |
+
# Try validator.hml first (project used this name), then validator.html
|
| 105 |
+
try:
|
| 106 |
+
return _templates.TemplateResponse("validator.hml", {"request": request})
|
| 107 |
+
except Exception:
|
| 108 |
+
return _templates.TemplateResponse(
|
| 109 |
+
"validator.html",
|
| 110 |
+
{"request": request, "warning": "validator service running in fallback mode"},
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
TAGS_METADATA = [
|
| 114 |
+
{"name": "Health", "description": "Liveness / readiness probes and basic service metadata."},
|
| 115 |
+
VALIDATOR_TAG,
|
| 116 |
+
# UI tag is implicit; only /home (Info) and /validator are exposed
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
@asynccontextmanager
|
| 120 |
+
async def lifespan(app: FastAPI):
|
| 121 |
+
app.state.started_at = time.time()
|
| 122 |
+
app.state.version = os.getenv("APP_VERSION", "1.0.0")
|
| 123 |
+
logger = logging.getLogger("uvicorn.error")
|
| 124 |
+
|
| 125 |
+
# ---- RAG INIT DISABLED ----
|
| 126 |
+
# try:
|
| 127 |
+
# if ensure_kb(out_jsonl="data/kb.jsonl", config_path="configs/rag_sources.yaml", skip_if_exists=True):
|
| 128 |
+
# logger.info("KB ready at data/kb.jsonl")
|
| 129 |
+
# else:
|
| 130 |
+
# logger.warning("KB build produced no records; running LLM-only.")
|
| 131 |
+
# except Exception as e:
|
| 132 |
+
# logger.warning("KB build failed (%s); running LLM-only.", e)
|
| 133 |
+
# logger.info("Warming up RAG retriever...")
|
| 134 |
+
# get_retriever(get_settings())
|
| 135 |
+
# logger.info("RAG retriever is ready.")
|
| 136 |
+
|
| 137 |
+
hf_token_present = bool(os.getenv("HF_TOKEN"))
|
| 138 |
+
logger.info(
|
| 139 |
+
"matrix-ai starting (version=%s, port=%s, hf_token_present=%s)",
|
| 140 |
+
app.state.version,
|
| 141 |
+
os.getenv("PORT", "7860"),
|
| 142 |
+
"yes" if hf_token_present else "no",
|
| 143 |
+
)
|
| 144 |
+
try:
|
| 145 |
+
yield
|
| 146 |
+
finally:
|
| 147 |
+
uptime = time.time() - getattr(app.state, "started_at", time.time())
|
| 148 |
+
logger.info("matrix-ai shutting down (uptime=%.2fs)", uptime)
|
| 149 |
+
|
| 150 |
+
def create_app() -> FastAPI:
|
| 151 |
+
app = FastAPI(
|
| 152 |
+
title="matrix-ai",
|
| 153 |
+
version=os.getenv("APP_VERSION", "1.0.0"),
|
| 154 |
+
description="Minimal service with A2A Validator and health endpoints",
|
| 155 |
+
openapi_tags=TAGS_METADATA,
|
| 156 |
+
docs_url="/docs",
|
| 157 |
+
redoc_url=None,
|
| 158 |
+
lifespan=lifespan,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Static files (for validator UI assets, etc.)
|
| 162 |
+
try:
|
| 163 |
+
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
| 164 |
+
except Exception:
|
| 165 |
+
pass
|
| 166 |
+
|
| 167 |
+
# Middlewares (gzip, CORS, rate-limit, req-logs, etc.)
|
| 168 |
+
attach_middlewares(app)
|
| 169 |
+
|
| 170 |
+
# Core info/router pages
|
| 171 |
+
app.include_router(health.router, tags=["Health"])
|
| 172 |
+
|
| 173 |
+
# Validator router
|
| 174 |
+
app.include_router(validator_router, tags=["Validator"])
|
| 175 |
+
|
| 176 |
+
# UI router (enables /home "Info" page and "/" redirect defined in ui.py)
|
| 177 |
+
app.include_router(ui_router)
|
| 178 |
+
|
| 179 |
+
# Alias so the frontend can POST /agent-card (script.js default target)
|
| 180 |
+
try:
|
| 181 |
+
from .services.validator_service import get_agent_card as _get_agent_card # type: ignore
|
| 182 |
+
app.add_api_route(
|
| 183 |
+
"/agent-card",
|
| 184 |
+
_get_agent_card,
|
| 185 |
+
methods=["POST"],
|
| 186 |
+
tags=["Validator"],
|
| 187 |
+
name="agent_card_alias",
|
| 188 |
+
)
|
| 189 |
+
logging.getLogger("uvicorn.error").info(
|
| 190 |
+
"Added alias: POST /agent-card → /validator/agent-card"
|
| 191 |
+
)
|
| 192 |
+
except Exception as e:
|
| 193 |
+
logging.getLogger("uvicorn.error").warning(
|
| 194 |
+
f"Failed to add /agent-card alias: {e}"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# Mount Socket.IO if available
|
| 198 |
+
if HAS_SOCKETIO and socketio_app is not None:
|
| 199 |
+
app.mount("/socket.io", socketio_app)
|
| 200 |
+
logging.getLogger("uvicorn.error").info("Mounted Socket.IO at /socket.io")
|
| 201 |
+
|
| 202 |
+
# IMPORTANT:
|
| 203 |
+
# Do NOT define extra "/" or "/home" handlers here.
|
| 204 |
+
# ui.py already defines:
|
| 205 |
+
# - GET "/" -> Redirect to /validator
|
| 206 |
+
# - GET "/home" -> Render home.html (Info tab)
|
| 207 |
+
# Keeping only one definition avoids duplicate-route conflicts.
|
| 208 |
+
|
| 209 |
+
return app
|
| 210 |
+
|
| 211 |
+
app = create_app()
|
app/middleware.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/middleware.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import time
|
| 5 |
+
import logging
|
| 6 |
+
import json
|
| 7 |
+
import asyncio
|
| 8 |
+
from typing import Callable, Optional
|
| 9 |
+
|
| 10 |
+
from anyio import EndOfStream
|
| 11 |
+
from fastapi import FastAPI, Request
|
| 12 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
+
from starlette.responses import Response, JSONResponse
|
| 14 |
+
from starlette.middleware.gzip import GZipMiddleware
|
| 15 |
+
from starlette.exceptions import ClientDisconnect
|
| 16 |
+
|
| 17 |
+
# Optional: python-json-logger for structured logs; fallback to a minimal JSON formatter.
|
| 18 |
+
try:
|
| 19 |
+
from pythonjsonlogger import jsonlogger # type: ignore
|
| 20 |
+
_HAS_PY_JSON_LOGGER = True
|
| 21 |
+
except Exception:
|
| 22 |
+
_HAS_PY_JSON_LOGGER = False
|
| 23 |
+
|
| 24 |
+
from .deps import get_settings
|
| 25 |
+
from .core.rate_limit import RateLimiter
|
| 26 |
+
from .core.logging import add_trace_id
|
| 27 |
+
|
| 28 |
+
class _SimpleJsonFormatter(logging.Formatter):
|
| 29 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 30 |
+
payload = {
|
| 31 |
+
"asctime": self.formatTime(record, "%Y-%m-%d %H:%M:%S"),
|
| 32 |
+
"name": record.name,
|
| 33 |
+
"levelname": record.levelname,
|
| 34 |
+
"message": record.getMessage(),
|
| 35 |
+
"trace_id": getattr(record, "trace_id", None),
|
| 36 |
+
}
|
| 37 |
+
try:
|
| 38 |
+
return json.dumps(payload, ensure_ascii=False)
|
| 39 |
+
except Exception:
|
| 40 |
+
return (
|
| 41 |
+
f'{payload["asctime"]} {payload["name"]} {payload["levelname"]} '
|
| 42 |
+
f'{payload["message"]} trace_id={payload["trace_id"]}'
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
_logger = logging.getLogger("matrix-ai")
|
| 46 |
+
if not _logger.handlers:
|
| 47 |
+
_logger.setLevel(logging.INFO)
|
| 48 |
+
_handler = logging.StreamHandler()
|
| 49 |
+
if _HAS_PY_JSON_LOGGER:
|
| 50 |
+
_formatter = jsonlogger.JsonFormatter(
|
| 51 |
+
"%(asctime)s %(name)s %(levelname)s %(message)s %(trace_id)s"
|
| 52 |
+
)
|
| 53 |
+
else:
|
| 54 |
+
_formatter = _SimpleJsonFormatter()
|
| 55 |
+
logging.getLogger("uvicorn.error").warning(
|
| 56 |
+
"python-json-logger not found; using a minimal JSON formatter."
|
| 57 |
+
)
|
| 58 |
+
_handler.setFormatter(_formatter)
|
| 59 |
+
_logger.addHandler(_handler)
|
| 60 |
+
|
| 61 |
+
_rate_limiter = RateLimiter()
|
| 62 |
+
_SSE_PATH_SUFFIXES = ("/chat/stream", "/v1/chat/stream")
|
| 63 |
+
_HEALTH_PATHS = ("/health", "/livez", "/readyz")
|
| 64 |
+
|
| 65 |
+
def _client_ip(request: Request) -> str:
|
| 66 |
+
xff = request.headers.get("x-forwarded-for")
|
| 67 |
+
if xff:
|
| 68 |
+
return xff.split(",")[0].strip()
|
| 69 |
+
return request.client.host if request.client else "unknown"
|
| 70 |
+
|
| 71 |
+
def _is_sse(request: Request, response: Optional[Response] = None) -> bool:
|
| 72 |
+
path = request.url.path
|
| 73 |
+
if path.endswith(_SSE_PATH_SUFFIXES):
|
| 74 |
+
return True
|
| 75 |
+
if response is not None:
|
| 76 |
+
ctype = response.headers.get("content-type", "")
|
| 77 |
+
if ctype.startswith("text/event-stream"):
|
| 78 |
+
return True
|
| 79 |
+
accept = request.headers.get("accept", "")
|
| 80 |
+
return "text/event-stream" in accept
|
| 81 |
+
|
| 82 |
+
def attach_middlewares(app: FastAPI) -> None:
|
| 83 |
+
app.add_middleware(GZipMiddleware, minimum_size=512)
|
| 84 |
+
app.add_middleware(
|
| 85 |
+
CORSMiddleware,
|
| 86 |
+
allow_origins=["*"],
|
| 87 |
+
allow_credentials=True,
|
| 88 |
+
allow_methods=["*"],
|
| 89 |
+
allow_headers=["*"],
|
| 90 |
+
expose_headers=["X-Trace-Id", "X-Process-Time-Ms", "Server-Timing"],
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
@app.middleware("http")
|
| 94 |
+
async def rate_limit_and_log_middleware(request: Request, call_next: Callable):
|
| 95 |
+
add_trace_id(request)
|
| 96 |
+
trace_id = getattr(request.state, "trace_id", "N/A")
|
| 97 |
+
|
| 98 |
+
path = request.url.path
|
| 99 |
+
method = request.method
|
| 100 |
+
ua = request.headers.get("user-agent", "-")
|
| 101 |
+
ip = _client_ip(request)
|
| 102 |
+
|
| 103 |
+
if path in _HEALTH_PATHS:
|
| 104 |
+
try:
|
| 105 |
+
response = await call_next(request)
|
| 106 |
+
except Exception:
|
| 107 |
+
return JSONResponse({"status": "unhealthy"}, status_code=500)
|
| 108 |
+
response.headers.setdefault("X-Trace-Id", str(trace_id))
|
| 109 |
+
return response
|
| 110 |
+
|
| 111 |
+
settings = get_settings()
|
| 112 |
+
if not _rate_limiter.allow(ip, path, settings.limits.rate_per_min):
|
| 113 |
+
_logger.warning(
|
| 114 |
+
"429 Too Many Requests from %s on %s",
|
| 115 |
+
ip, path, extra={"trace_id": trace_id},
|
| 116 |
+
)
|
| 117 |
+
return JSONResponse({"detail": "Too Many Requests"}, status_code=429,
|
| 118 |
+
headers={"X-Trace-Id": str(trace_id)})
|
| 119 |
+
|
| 120 |
+
t0 = time.time()
|
| 121 |
+
try:
|
| 122 |
+
response = await call_next(request)
|
| 123 |
+
|
| 124 |
+
# --- NEW: treat disconnects as benign (return 204) ---
|
| 125 |
+
except (EndOfStream, ClientDisconnect, asyncio.CancelledError):
|
| 126 |
+
_logger.info(
|
| 127 |
+
"Client disconnected from stream. Path: %s, IP: %s",
|
| 128 |
+
path, ip, extra={"trace_id": trace_id},
|
| 129 |
+
)
|
| 130 |
+
resp = Response(status_code=204)
|
| 131 |
+
resp.headers.setdefault("X-Trace-Id", str(trace_id))
|
| 132 |
+
return resp
|
| 133 |
+
|
| 134 |
+
except RuntimeError as e:
|
| 135 |
+
# Starlette sometimes wraps EndOfStream as this RuntimeError
|
| 136 |
+
if str(e) == "No response returned.":
|
| 137 |
+
_logger.info(
|
| 138 |
+
"Downstream produced no response (likely streaming disconnect). "
|
| 139 |
+
"Path: %s, IP: %s",
|
| 140 |
+
path, ip, extra={"trace_id": trace_id},
|
| 141 |
+
)
|
| 142 |
+
resp = Response(status_code=204)
|
| 143 |
+
resp.headers.setdefault("X-Trace-Id", str(trace_id))
|
| 144 |
+
return resp
|
| 145 |
+
# not a disconnect case → re-raise to be handled below
|
| 146 |
+
raise
|
| 147 |
+
|
| 148 |
+
except Exception as e:
|
| 149 |
+
_logger.exception(
|
| 150 |
+
"Unhandled error while processing %s %s: %s",
|
| 151 |
+
method, path, e, extra={"trace_id": trace_id},
|
| 152 |
+
)
|
| 153 |
+
dur_ms = (time.time() - t0) * 1000.0
|
| 154 |
+
return JSONResponse(
|
| 155 |
+
{"detail": "Internal Server Error"},
|
| 156 |
+
status_code=500,
|
| 157 |
+
headers={
|
| 158 |
+
"X-Trace-Id": str(trace_id),
|
| 159 |
+
"X-Process-Time-Ms": f"{dur_ms:.2f}",
|
| 160 |
+
"Server-Timing": f"app;dur={dur_ms:.2f}",
|
| 161 |
+
},
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
if not isinstance(response, Response):
|
| 165 |
+
_logger.error("Downstream returned no Response object for %s",
|
| 166 |
+
path, extra={"trace_id": trace_id})
|
| 167 |
+
return JSONResponse({"detail": "Internal Server Error"},
|
| 168 |
+
status_code=500,
|
| 169 |
+
headers={"X-Trace-Id": str(trace_id)})
|
| 170 |
+
|
| 171 |
+
sse = _is_sse(request, response)
|
| 172 |
+
dur_ms = (time.time() - t0) * 1000.0
|
| 173 |
+
response.headers.setdefault("X-Trace-Id", str(trace_id))
|
| 174 |
+
response.headers.setdefault("X-Process-Time-Ms", f"{dur_ms:.2f}")
|
| 175 |
+
response.headers.setdefault("Server-Timing", f"app;dur={dur_ms:.2f}")
|
| 176 |
+
|
| 177 |
+
if sse:
|
| 178 |
+
response.headers.setdefault("Cache-Control", "no-cache")
|
| 179 |
+
_logger.info(
|
| 180 |
+
'"%s %s" %s (SSE) ip=%s ua="%s" %.2fms',
|
| 181 |
+
method, path, response.status_code, ip, ua, dur_ms,
|
| 182 |
+
extra={"trace_id": trace_id},
|
| 183 |
+
)
|
| 184 |
+
return response
|
| 185 |
+
|
| 186 |
+
_logger.info(
|
| 187 |
+
'"%s %s" %s ip=%s ua="%s" %.2fms',
|
| 188 |
+
method, path, response.status_code, ip, ua, dur_ms,
|
| 189 |
+
extra={"trace_id": trace_id},
|
| 190 |
+
)
|
| 191 |
+
return response
|
app/routers/__init__.py
ADDED
|
File without changes
|
app/routers/health.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
router = APIRouter()
|
| 4 |
+
|
| 5 |
+
@router.get("/healthz", summary="Liveness Probe")
|
| 6 |
+
async def healthz():
|
| 7 |
+
"""Checks if the service is running."""
|
| 8 |
+
return {"status": "ok"}
|
| 9 |
+
|
| 10 |
+
@router.get("/readyz", summary="Readiness Probe")
|
| 11 |
+
async def readyz():
|
| 12 |
+
"""Checks if the service is ready to accept traffic."""
|
| 13 |
+
# In a real app, this would check dependencies like model loading status.
|
| 14 |
+
return {"ready": True}
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/validator_service.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/services/validator_service.py
|
| 2 |
+
"""
|
| 3 |
+
A2A Validator service.
|
| 4 |
+
- Provides /validator (UI) + /validator/agent-card (HTTP) routes.
|
| 5 |
+
- Defines all Socket.IO event handlers.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Any
|
| 11 |
+
from urllib.parse import urlparse, urlunparse
|
| 12 |
+
from uuid import uuid4
|
| 13 |
+
|
| 14 |
+
import bleach
|
| 15 |
+
import httpx
|
| 16 |
+
|
| 17 |
+
# Socket.IO is optional; create shims when missing
|
| 18 |
+
try:
|
| 19 |
+
import socketio # type: ignore
|
| 20 |
+
HAS_SOCKETIO = True
|
| 21 |
+
except Exception: # pragma: no cover
|
| 22 |
+
socketio = None # type: ignore
|
| 23 |
+
HAS_SOCKETIO = False
|
| 24 |
+
|
| 25 |
+
from fastapi import APIRouter, Request
|
| 26 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 27 |
+
from fastapi.templating import Jinja2Templates
|
| 28 |
+
from jinja2 import TemplateNotFound
|
| 29 |
+
|
| 30 |
+
# Conditional import for A2A SDK (optional)
|
| 31 |
+
try:
|
| 32 |
+
from a2a.client import A2ACardResolver, A2AClient
|
| 33 |
+
from a2a.types import (
|
| 34 |
+
AgentCard,
|
| 35 |
+
JSONRPCErrorResponse,
|
| 36 |
+
Message,
|
| 37 |
+
MessageSendConfiguration,
|
| 38 |
+
MessageSendParams,
|
| 39 |
+
Role,
|
| 40 |
+
SendMessageRequest,
|
| 41 |
+
SendMessageResponse,
|
| 42 |
+
SendStreamingMessageRequest,
|
| 43 |
+
SendStreamingMessageResponse,
|
| 44 |
+
TextPart,
|
| 45 |
+
)
|
| 46 |
+
HAS_A2A = True
|
| 47 |
+
except Exception:
|
| 48 |
+
HAS_A2A = False
|
| 49 |
+
# Dummy stand-ins so type hints won’t explode
|
| 50 |
+
AgentCard = JSONRPCErrorResponse = Message = MessageSendConfiguration = object # type: ignore
|
| 51 |
+
MessageSendParams = Role = SendMessageRequest = SendMessageResponse = object # type: ignore
|
| 52 |
+
SendStreamingMessageRequest = SendStreamingMessageResponse = TextPart = object # type: ignore
|
| 53 |
+
A2ACardResolver = A2AClient = object # type: ignore
|
| 54 |
+
|
| 55 |
+
from app import validators # local validators.py
|
| 56 |
+
|
| 57 |
+
# ==============================================================================
|
| 58 |
+
# Setup
|
| 59 |
+
# ==============================================================================
|
| 60 |
+
logger = logging.getLogger("uvicorn.error")
|
| 61 |
+
|
| 62 |
+
if HAS_SOCKETIO:
|
| 63 |
+
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
|
| 64 |
+
socketio_app = socketio.ASGIApp(sio)
|
| 65 |
+
else:
|
| 66 |
+
class _SioShim:
|
| 67 |
+
async def emit(self, *a, **k): # no-op
|
| 68 |
+
pass
|
| 69 |
+
|
| 70 |
+
def on(self, *a, **k):
|
| 71 |
+
def _wrap(f):
|
| 72 |
+
return f
|
| 73 |
+
return _wrap
|
| 74 |
+
|
| 75 |
+
event = on
|
| 76 |
+
|
| 77 |
+
sio = _SioShim()
|
| 78 |
+
socketio_app = None
|
| 79 |
+
|
| 80 |
+
router = APIRouter(prefix="/validator", tags=["Validator"])
|
| 81 |
+
templates = Jinja2Templates(directory="app/templates")
|
| 82 |
+
|
| 83 |
+
STANDARD_HEADERS = {
|
| 84 |
+
"host",
|
| 85 |
+
"user-agent",
|
| 86 |
+
"accept",
|
| 87 |
+
"content-type",
|
| 88 |
+
"content-length",
|
| 89 |
+
"connection",
|
| 90 |
+
"accept-encoding",
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
# ==============================================================================
|
| 94 |
+
# State Management
|
| 95 |
+
# ==============================================================================
|
| 96 |
+
clients: dict[str, tuple[httpx.AsyncClient, Any, Any]] = {}
|
| 97 |
+
|
| 98 |
+
# ==============================================================================
|
| 99 |
+
# Helpers
|
| 100 |
+
# ==============================================================================
|
| 101 |
+
async def _emit_debug_log(sid: str, event_id: str, log_type: str, data: Any) -> None:
|
| 102 |
+
await sio.emit("debug_log", {"type": log_type, "data": data, "id": event_id}, to=sid)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
async def _process_a2a_response(result: Any, sid: str, request_id: str) -> None:
|
| 106 |
+
if not HAS_A2A:
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
if isinstance(result.root, JSONRPCErrorResponse):
|
| 110 |
+
error_data = result.root.error.model_dump(exclude_none=True)
|
| 111 |
+
await _emit_debug_log(sid, request_id, "error", error_data)
|
| 112 |
+
await sio.emit(
|
| 113 |
+
"agent_response",
|
| 114 |
+
{"error": error_data.get("message", "Unknown error"), "id": request_id},
|
| 115 |
+
to=sid,
|
| 116 |
+
)
|
| 117 |
+
return
|
| 118 |
+
|
| 119 |
+
event = result.root.result
|
| 120 |
+
response_id = getattr(event, "id", request_id)
|
| 121 |
+
response_data = event.model_dump(exclude_none=True)
|
| 122 |
+
response_data["id"] = response_id
|
| 123 |
+
response_data["validation_errors"] = validators.validate_message(response_data)
|
| 124 |
+
|
| 125 |
+
await _emit_debug_log(sid, response_id, "response", response_data)
|
| 126 |
+
await sio.emit("agent_response", response_data, to=sid)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def get_card_resolver(client: httpx.AsyncClient, agent_card_url: str) -> Any:
|
| 130 |
+
if not HAS_A2A:
|
| 131 |
+
return None
|
| 132 |
+
parsed_url = urlparse(agent_card_url)
|
| 133 |
+
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
| 134 |
+
path_with_query = urlunparse(("", "", parsed_url.path, "", parsed_url.query, ""))
|
| 135 |
+
card_path = path_with_query.lstrip("/")
|
| 136 |
+
if card_path:
|
| 137 |
+
return A2ACardResolver(client, base_url, agent_card_path=card_path)
|
| 138 |
+
return A2ACardResolver(client, base_url)
|
| 139 |
+
|
| 140 |
+
# ==============================================================================
|
| 141 |
+
# FastAPI Routes
|
| 142 |
+
# ==============================================================================
|
| 143 |
+
@router.get("/", response_class=HTMLResponse)
|
| 144 |
+
async def validator_ui(request: Request) -> HTMLResponse:
|
| 145 |
+
# Prefer validator.hml (your current file), fallback to validator.html
|
| 146 |
+
for name in ("validator.hml", "validator.html"):
|
| 147 |
+
try:
|
| 148 |
+
return templates.TemplateResponse(name, {"request": request})
|
| 149 |
+
except TemplateNotFound:
|
| 150 |
+
continue
|
| 151 |
+
# If neither exists, return a minimal message
|
| 152 |
+
return HTMLResponse("<h3>Validator UI template not found.</h3>", status_code=500)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@router.post("/agent-card")
|
| 156 |
+
async def get_agent_card(request: Request) -> JSONResponse:
|
| 157 |
+
"""
|
| 158 |
+
Fetch and validate an Agent Card from a URL.
|
| 159 |
+
|
| 160 |
+
If A2A SDK is installed, use its resolver.
|
| 161 |
+
Otherwise, be lenient: follow redirects and probe common well-known paths.
|
| 162 |
+
"""
|
| 163 |
+
# Parse request body
|
| 164 |
+
try:
|
| 165 |
+
request_data = await request.json()
|
| 166 |
+
agent_url = (request_data.get("url") or "").strip()
|
| 167 |
+
sid = request_data.get("sid")
|
| 168 |
+
if not agent_url or not sid:
|
| 169 |
+
return JSONResponse({"error": "Agent URL and SID are required."}, status_code=400)
|
| 170 |
+
except Exception:
|
| 171 |
+
return JSONResponse({"error": "Invalid request body."}, status_code=400)
|
| 172 |
+
|
| 173 |
+
# Collect custom headers (forwarded to the target)
|
| 174 |
+
custom_headers = {
|
| 175 |
+
name: value
|
| 176 |
+
for name, value in request.headers.items()
|
| 177 |
+
if name.lower() not in STANDARD_HEADERS
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
await _emit_debug_log(
|
| 181 |
+
sid,
|
| 182 |
+
"http-agent-card",
|
| 183 |
+
"request",
|
| 184 |
+
{"endpoint": "/agent-card", "payload": request_data, "custom_headers": custom_headers},
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Fetch the agent card
|
| 188 |
+
try:
|
| 189 |
+
async with httpx.AsyncClient(
|
| 190 |
+
timeout=30.0,
|
| 191 |
+
headers=custom_headers,
|
| 192 |
+
follow_redirects=True, # <<< important for 3xx like 307 to /docs
|
| 193 |
+
) as client:
|
| 194 |
+
if HAS_A2A:
|
| 195 |
+
# Preferred path: let the resolver figure out the right card location
|
| 196 |
+
card_resolver = get_card_resolver(client, agent_url)
|
| 197 |
+
card = await card_resolver.get_agent_card()
|
| 198 |
+
card_data = card.model_dump(exclude_none=True)
|
| 199 |
+
else:
|
| 200 |
+
# Fallback: try what the user typed first; if non-JSON, probe common paths
|
| 201 |
+
tried: list[str] = []
|
| 202 |
+
|
| 203 |
+
async def _try(url: str) -> dict[str, Any]:
|
| 204 |
+
r = await client.get(url)
|
| 205 |
+
r.raise_for_status()
|
| 206 |
+
ctype = (r.headers.get("content-type") or "").lower()
|
| 207 |
+
if "application/json" in ctype or ctype.endswith("+json"):
|
| 208 |
+
return r.json()
|
| 209 |
+
# If we got HTML or something else, raise to trigger probing
|
| 210 |
+
raise ValueError(f"Non-JSON response (content-type={ctype or 'unknown'}) at {url}")
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
card_data = await _try(agent_url)
|
| 214 |
+
except Exception:
|
| 215 |
+
# If the user pasted a base/root URL, probe common Agent Card paths on same host
|
| 216 |
+
parsed = urlparse(agent_url)
|
| 217 |
+
base = f"{parsed.scheme}://{parsed.netloc}"
|
| 218 |
+
candidates = [
|
| 219 |
+
agent_url, # original again (in case it became JSON after redirect)
|
| 220 |
+
f"{base}/.well-known/agent.json",
|
| 221 |
+
f"{base}/.well-known/ai-agent.json",
|
| 222 |
+
f"{base}/agent-card",
|
| 223 |
+
f"{base}/agent.json",
|
| 224 |
+
]
|
| 225 |
+
err: Exception | None = None
|
| 226 |
+
card_data = None
|
| 227 |
+
for u in candidates:
|
| 228 |
+
if u in tried:
|
| 229 |
+
continue
|
| 230 |
+
tried.append(u)
|
| 231 |
+
try:
|
| 232 |
+
card_data = await _try(u)
|
| 233 |
+
agent_url = u # record the working URL
|
| 234 |
+
break
|
| 235 |
+
except Exception as e:
|
| 236 |
+
err = e
|
| 237 |
+
if card_data is None:
|
| 238 |
+
raise RuntimeError(
|
| 239 |
+
f"Could not find a JSON Agent Card at {agent_url} (last error: {err})"
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
# Validate locally
|
| 243 |
+
validation_errors = validators.validate_agent_card(card_data) # type: ignore[arg-type]
|
| 244 |
+
response = {
|
| 245 |
+
"card": card_data,
|
| 246 |
+
"validation_errors": validation_errors,
|
| 247 |
+
"resolved_url": agent_url,
|
| 248 |
+
}
|
| 249 |
+
status = 200
|
| 250 |
+
|
| 251 |
+
except httpx.RequestError as e:
|
| 252 |
+
response = {"error": f"Failed to connect to agent: {e}"}
|
| 253 |
+
status = 502
|
| 254 |
+
except Exception as e:
|
| 255 |
+
response = {"error": f"An internal server error occurred: {e}"}
|
| 256 |
+
status = 500
|
| 257 |
+
|
| 258 |
+
await _emit_debug_log(sid, "http-agent-card", "response", {"status": status, "payload": response})
|
| 259 |
+
return JSONResponse(content=response, status_code=status)
|
| 260 |
+
|
| 261 |
+
# ==============================================================================
|
| 262 |
+
# Socket.IO Event Handlers
|
| 263 |
+
# ==============================================================================
|
| 264 |
+
@sio.on("connect")
|
| 265 |
+
async def handle_connect(sid: str, environ: dict[str, Any]) -> None: # type: ignore[misc]
|
| 266 |
+
logger.info(f"Client connected: {sid}")
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
@sio.on("disconnect")
|
| 270 |
+
async def handle_disconnect(sid: str) -> None: # type: ignore[misc]
|
| 271 |
+
logger.info(f"Client disconnected: {sid}")
|
| 272 |
+
if sid in clients:
|
| 273 |
+
httpx_client, _, _ = clients.pop(sid)
|
| 274 |
+
await httpx_client.aclose()
|
| 275 |
+
logger.info(f"Cleaned up client for {sid}")
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
@sio.on("initialize_client")
|
| 279 |
+
async def handle_initialize_client(sid: str, data: dict[str, Any]) -> None: # type: ignore[misc]
|
| 280 |
+
"""
|
| 281 |
+
Prepare an A2A client for chat/streaming. If a2a is not installed, reply with a warning
|
| 282 |
+
so the UI still proceeds (card viewing still works via HTTP).
|
| 283 |
+
"""
|
| 284 |
+
if not HAS_A2A:
|
| 285 |
+
await sio.emit(
|
| 286 |
+
"client_initialized",
|
| 287 |
+
{"status": "warning", "message": "A2A SDK not installed; chat/streaming disabled."},
|
| 288 |
+
to=sid,
|
| 289 |
+
)
|
| 290 |
+
return
|
| 291 |
+
|
| 292 |
+
agent_card_url = data.get("url")
|
| 293 |
+
custom_headers = data.get("customHeaders", {})
|
| 294 |
+
if not agent_card_url:
|
| 295 |
+
await sio.emit("client_initialized", {"status": "error", "message": "Agent URL is required."}, to=sid)
|
| 296 |
+
return
|
| 297 |
+
|
| 298 |
+
try:
|
| 299 |
+
httpx_client = httpx.AsyncClient(timeout=600.0, headers=custom_headers)
|
| 300 |
+
card_resolver = get_card_resolver(httpx_client, agent_card_url)
|
| 301 |
+
card = await card_resolver.get_agent_card()
|
| 302 |
+
a2a_client = A2AClient(httpx_client, agent_card=card)
|
| 303 |
+
clients[sid] = (httpx_client, a2a_client, card)
|
| 304 |
+
await sio.emit("client_initialized", {"status": "success"}, to=sid)
|
| 305 |
+
except Exception as e:
|
| 306 |
+
await sio.emit("client_initialized", {"status": "error", "message": str(e)}, to=sid)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
@sio.on("send_message")
|
| 310 |
+
async def handle_send_message(sid: str, json_data: dict[str, Any]) -> None: # type: ignore[misc]
|
| 311 |
+
if not HAS_A2A:
|
| 312 |
+
await sio.emit("agent_response", {"error": "A2A SDK not installed", "id": json_data.get("id")}, to=sid)
|
| 313 |
+
return
|
| 314 |
+
|
| 315 |
+
message_text = bleach.clean(json_data.get("message", ""))
|
| 316 |
+
message_id = json_data.get("id", str(uuid4()))
|
| 317 |
+
context_id = json_data.get("contextId")
|
| 318 |
+
metadata = json_data.get("metadata", {})
|
| 319 |
+
|
| 320 |
+
if sid not in clients:
|
| 321 |
+
await sio.emit("agent_response", {"error": "Client not initialized.", "id": message_id}, to=sid)
|
| 322 |
+
return
|
| 323 |
+
|
| 324 |
+
_, a2a_client, card = clients[sid]
|
| 325 |
+
|
| 326 |
+
message = Message(
|
| 327 |
+
role=Role.user,
|
| 328 |
+
parts=[TextPart(text=str(message_text))], # type: ignore[list-item]
|
| 329 |
+
message_id=message_id,
|
| 330 |
+
context_id=context_id,
|
| 331 |
+
metadata=metadata,
|
| 332 |
+
)
|
| 333 |
+
payload = MessageSendParams(
|
| 334 |
+
message=message,
|
| 335 |
+
configuration=MessageSendConfiguration(accepted_output_modes=["text/plain", "video/mp4"]),
|
| 336 |
+
)
|
| 337 |
+
supports_streaming = hasattr(card.capabilities, "streaming") and card.capabilities.streaming is True
|
| 338 |
+
|
| 339 |
+
try:
|
| 340 |
+
if supports_streaming:
|
| 341 |
+
stream_request = SendStreamingMessageRequest(
|
| 342 |
+
id=message_id, method="message/stream", jsonrpc="2.0", params=payload
|
| 343 |
+
)
|
| 344 |
+
await _emit_debug_log(sid, message_id, "request", stream_request.model_dump(exclude_none=True))
|
| 345 |
+
response_stream = a2a_client.send_message_streaming(stream_request)
|
| 346 |
+
async for stream_result in response_stream:
|
| 347 |
+
await _process_a2a_response(stream_result, sid, message_id)
|
| 348 |
+
else:
|
| 349 |
+
send_message_request = SendMessageRequest(
|
| 350 |
+
id=message_id, method="message/send", jsonrpc="2.0", params=payload
|
| 351 |
+
)
|
| 352 |
+
await _emit_debug_log(sid, message_id, "request", send_message_request.model_dump(exclude_none=True))
|
| 353 |
+
send_result = await a2a_client.send_message(send_message_request)
|
| 354 |
+
await _process_a2a_response(send_result, sid, message_id)
|
| 355 |
+
except Exception as e:
|
| 356 |
+
await sio.emit("agent_response", {"error": f"Failed to send message: {e}", "id": message_id}, to=sid)
|
| 357 |
+
|
| 358 |
+
__all__ = ["router", "socketio_app", "HAS_SOCKETIO"]
|
app/static/script.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/static/styles.css
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ================================
|
| 2 |
+
MatrixHub / A2A Validator Styles
|
| 3 |
+
(app/static/styles.css)
|
| 4 |
+
================================ */
|
| 5 |
+
|
| 6 |
+
/* Theme tokens (inherits nicely with base.html) */
|
| 7 |
+
:root {
|
| 8 |
+
--bg: #020402;
|
| 9 |
+
--bg2: #071007;
|
| 10 |
+
--surface: #061006cc;
|
| 11 |
+
--text: #c8facc;
|
| 12 |
+
--muted: #9aa29a;
|
| 13 |
+
--matrix: #00ff9c;
|
| 14 |
+
--border: rgba(0,255,156,0.18);
|
| 15 |
+
--border-strong: #0d1e0f;
|
| 16 |
+
--code-bg: #020a04; /* solid code background to stop canvas bleed */
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* Container (kept for compatibility with older markup) */
|
| 20 |
+
.container {
|
| 21 |
+
max-width: 980px;
|
| 22 |
+
margin: 2rem auto;
|
| 23 |
+
font-family: "Share Tech Mono", monospace;
|
| 24 |
+
color: var(--text);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* Simple spacing helpers used by script.js flows */
|
| 28 |
+
.url-input-container,
|
| 29 |
+
.connect-btn-container,
|
| 30 |
+
.chat-input-container {
|
| 31 |
+
margin: 1rem 0;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/* Key-Value rows (HTTP headers / metadata rows) */
|
| 35 |
+
.kv-row {
|
| 36 |
+
display: grid;
|
| 37 |
+
grid-template-columns: 1fr 1fr auto;
|
| 38 |
+
gap: .5rem;
|
| 39 |
+
margin-bottom: .5rem;
|
| 40 |
+
align-items: center;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.kv-row input[type="text"],
|
| 44 |
+
.kv-row input[type="search"],
|
| 45 |
+
.kv-row input[type="url"] {
|
| 46 |
+
background: rgba(0,0,0,0.35);
|
| 47 |
+
border: 1px solid var(--border);
|
| 48 |
+
border-radius: 10px;
|
| 49 |
+
color: var(--text);
|
| 50 |
+
padding: 10px 12px;
|
| 51 |
+
outline: none;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.kv-row input:focus {
|
| 55 |
+
border-color: var(--matrix);
|
| 56 |
+
box-shadow: 0 0 0 3px rgba(0,255,156,0.08);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.kv-del {
|
| 60 |
+
padding: .45rem .7rem;
|
| 61 |
+
border-radius: 10px;
|
| 62 |
+
border: 1px solid var(--border);
|
| 63 |
+
color: var(--muted);
|
| 64 |
+
background: rgba(0,0,0,0.25);
|
| 65 |
+
cursor: pointer;
|
| 66 |
+
transition: filter .15s ease, box-shadow .15s ease, background .15s ease;
|
| 67 |
+
}
|
| 68 |
+
.kv-del:hover {
|
| 69 |
+
color: #032215;
|
| 70 |
+
background: linear-gradient(180deg, #00ff9c, #00c97e);
|
| 71 |
+
box-shadow: 0 8px 24px rgba(0,255,156,0.25);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* Collapsible helpers */
|
| 75 |
+
.collapsible-content.hidden,
|
| 76 |
+
.hidden { display: none; }
|
| 77 |
+
|
| 78 |
+
/* Chat bubbles (dark theme) */
|
| 79 |
+
.chat-bubble {
|
| 80 |
+
border: 1px solid var(--border);
|
| 81 |
+
border-radius: 10px;
|
| 82 |
+
padding: .6rem .7rem;
|
| 83 |
+
margin: .5rem 0;
|
| 84 |
+
background: rgba(0,0,0,0.25);
|
| 85 |
+
cursor: pointer;
|
| 86 |
+
transition: box-shadow .15s ease, transform .05s ease, background .15s ease;
|
| 87 |
+
}
|
| 88 |
+
.chat-bubble:hover {
|
| 89 |
+
background: rgba(0,0,0,0.35);
|
| 90 |
+
}
|
| 91 |
+
.chat-bubble:active {
|
| 92 |
+
transform: translateY(1px);
|
| 93 |
+
}
|
| 94 |
+
.chat-bubble.ok {
|
| 95 |
+
box-shadow: 0 0 0 1px rgba(0,255,156,0.18);
|
| 96 |
+
}
|
| 97 |
+
.chat-bubble.warn {
|
| 98 |
+
box-shadow: 0 0 0 1px rgba(255,196,0,0.22);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/* Chat bubble header line */
|
| 102 |
+
.chat-head {
|
| 103 |
+
font-weight: 700;
|
| 104 |
+
color: var(--matrix);
|
| 105 |
+
margin-bottom: .25rem;
|
| 106 |
+
letter-spacing: .02em;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/* Status colors used in various places */
|
| 110 |
+
.ok { color: #35d08c; }
|
| 111 |
+
.warn { color: #e2a339; }
|
| 112 |
+
|
| 113 |
+
/* Placeholder/aux text */
|
| 114 |
+
.placeholder-text { color: var(--muted); }
|
| 115 |
+
|
| 116 |
+
/* ===============================
|
| 117 |
+
Debug Console (dark, sticky)
|
| 118 |
+
=============================== */
|
| 119 |
+
#debug-console {
|
| 120 |
+
position: fixed;
|
| 121 |
+
right: 1rem;
|
| 122 |
+
bottom: 1rem;
|
| 123 |
+
width: 460px;
|
| 124 |
+
max-height: 60vh;
|
| 125 |
+
background: #0a140a;
|
| 126 |
+
border: 1px solid var(--border);
|
| 127 |
+
border-radius: 12px;
|
| 128 |
+
overflow: hidden;
|
| 129 |
+
box-shadow: 0 12px 36px rgba(0,0,0,.35), 0 0 0 1px rgba(0,255,156,.05);
|
| 130 |
+
z-index: 1000;
|
| 131 |
+
}
|
| 132 |
+
#debug-console.hidden { display: none; }
|
| 133 |
+
|
| 134 |
+
#debug-handle {
|
| 135 |
+
background: #000;
|
| 136 |
+
color: var(--text);
|
| 137 |
+
padding: .6rem .8rem;
|
| 138 |
+
display: flex;
|
| 139 |
+
align-items: center;
|
| 140 |
+
justify-content: space-between;
|
| 141 |
+
letter-spacing: .08em;
|
| 142 |
+
border-bottom: 1px solid var(--border);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
#debug-content {
|
| 146 |
+
padding: .6rem .8rem;
|
| 147 |
+
max-height: 48vh;
|
| 148 |
+
overflow: auto;
|
| 149 |
+
font-family: "Share Tech Mono", monospace;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/* Make logged JSON blocks readable and SOLID (no canvas bleed-through) */
|
| 153 |
+
#debug-content pre {
|
| 154 |
+
background: var(--code-bg) !important;
|
| 155 |
+
color: var(--text);
|
| 156 |
+
border: 1px solid var(--border);
|
| 157 |
+
border-radius: 10px;
|
| 158 |
+
padding: 10px;
|
| 159 |
+
white-space: pre-wrap;
|
| 160 |
+
word-break: break-word;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/* ===============================
|
| 164 |
+
Code / Pre blocks — OPAQUE FIX
|
| 165 |
+
=============================== */
|
| 166 |
+
|
| 167 |
+
/* Global default for code/pre used across validator */
|
| 168 |
+
pre, code, pre code {
|
| 169 |
+
background: var(--code-bg) !important; /* solid background */
|
| 170 |
+
color: var(--text);
|
| 171 |
+
border: 1px solid var(--border);
|
| 172 |
+
border-radius: 12px;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* Specific areas we know render JSON (agent card, modal, chat bubbles) */
|
| 176 |
+
#agent-card-content,
|
| 177 |
+
.agent-card-display pre,
|
| 178 |
+
.agent-card-display pre code,
|
| 179 |
+
#modal-json-content,
|
| 180 |
+
.chat-bubble pre {
|
| 181 |
+
background: var(--code-bg) !important;
|
| 182 |
+
backdrop-filter: none !important;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
/* Highlight.js fallback (some themes force their own bg) */
|
| 186 |
+
.hljs,
|
| 187 |
+
code.hljs {
|
| 188 |
+
background: var(--code-bg) !important;
|
| 189 |
+
color: var(--text);
|
| 190 |
+
border-radius: 10px;
|
| 191 |
+
padding: 10px;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/* ===============================
|
| 195 |
+
Buttons (generic)
|
| 196 |
+
=============================== */
|
| 197 |
+
button,
|
| 198 |
+
.button {
|
| 199 |
+
background: linear-gradient(180deg, #00ff9c, #00c97e);
|
| 200 |
+
color: #032215;
|
| 201 |
+
border: 0;
|
| 202 |
+
padding: .6rem .9rem;
|
| 203 |
+
border-radius: 12px;
|
| 204 |
+
font-weight: 800;
|
| 205 |
+
font-family: "Share Tech Mono", monospace;
|
| 206 |
+
letter-spacing: .03em;
|
| 207 |
+
cursor: pointer;
|
| 208 |
+
box-shadow: 0 8px 24px rgba(0,255,156,0.25);
|
| 209 |
+
transition: transform .05s ease, box-shadow .15s ease, filter .15s ease;
|
| 210 |
+
}
|
| 211 |
+
button:hover,
|
| 212 |
+
.button:hover { filter: brightness(1.05); box-shadow: 0 12px 36px rgba(0,255,156,0.35); }
|
| 213 |
+
button:active,
|
| 214 |
+
.button:active { transform: translateY(1px); }
|
| 215 |
+
button:disabled {
|
| 216 |
+
background: #0b1a12;
|
| 217 |
+
color: var(--muted);
|
| 218 |
+
border: 1px solid var(--border);
|
| 219 |
+
box-shadow: none;
|
| 220 |
+
cursor: not-allowed;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
/* Inputs (generic) */
|
| 224 |
+
input[type="text"],
|
| 225 |
+
input[type="search"],
|
| 226 |
+
input[type="url"] {
|
| 227 |
+
width: 100%;
|
| 228 |
+
color: var(--text);
|
| 229 |
+
background: var(--code-bg);
|
| 230 |
+
border: 1px solid var(--border);
|
| 231 |
+
border-radius: 12px;
|
| 232 |
+
padding: 10px 12px;
|
| 233 |
+
font-size: 14px;
|
| 234 |
+
font-family: "Share Tech Mono", monospace;
|
| 235 |
+
outline: none;
|
| 236 |
+
transition: border-color .15s ease, box-shadow .15s ease;
|
| 237 |
+
}
|
| 238 |
+
input[type="text"]:focus,
|
| 239 |
+
input[type="search"]:focus,
|
| 240 |
+
input[type="url"]:focus {
|
| 241 |
+
border-color: var(--matrix);
|
| 242 |
+
box-shadow: 0 0 0 3px rgba(0,255,156,0.08), 0 0 12px rgba(0,255,156,0.25) inset;
|
| 243 |
+
}
|
app/templates/base.html
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>matrix-ai</title>
|
| 7 |
+
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Inter:wght@400;600&display=swap" rel="stylesheet">
|
| 11 |
+
|
| 12 |
+
<style>
|
| 13 |
+
:root {
|
| 14 |
+
--bg: #020402;
|
| 15 |
+
--bg2: #071007;
|
| 16 |
+
--text: #c8facc;
|
| 17 |
+
--muted: #7ef7a7;
|
| 18 |
+
--matrix: #00ff9c;
|
| 19 |
+
--card: #061006cc; /* translucent */
|
| 20 |
+
--border: #0d1e0f;
|
| 21 |
+
}
|
| 22 |
+
html, body { height: 100%; }
|
| 23 |
+
body {
|
| 24 |
+
margin: 0;
|
| 25 |
+
color: var(--text);
|
| 26 |
+
background:
|
| 27 |
+
radial-gradient(1200px 800px at 100% -10%, rgba(0,255,156,0.06), transparent 40%),
|
| 28 |
+
radial-gradient(1000px 600px at -10% 100%, rgba(0,255,156,0.05), transparent 40%),
|
| 29 |
+
linear-gradient(180deg, var(--bg), var(--bg2) 60%, var(--bg));
|
| 30 |
+
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
| 31 |
+
overflow-y: auto;
|
| 32 |
+
}
|
| 33 |
+
body::after {
|
| 34 |
+
content: "";
|
| 35 |
+
position: fixed;
|
| 36 |
+
inset: 0;
|
| 37 |
+
pointer-events: none;
|
| 38 |
+
background: repeating-linear-gradient(to bottom, rgba(0,0,0,0.06) 0px, rgba(0,0,0,0.06) 1px, transparent 2px, transparent 3px);
|
| 39 |
+
mix-blend-mode: overlay;
|
| 40 |
+
opacity: 0.6;
|
| 41 |
+
z-index: 0;
|
| 42 |
+
}
|
| 43 |
+
#code-rain {
|
| 44 |
+
position: fixed;
|
| 45 |
+
inset: 0;
|
| 46 |
+
width: 100vw;
|
| 47 |
+
height: 100vh;
|
| 48 |
+
z-index: 0;
|
| 49 |
+
pointer-events: none;
|
| 50 |
+
opacity: 0.65;
|
| 51 |
+
}
|
| 52 |
+
header {
|
| 53 |
+
position: sticky; top: 0; z-index: 3;
|
| 54 |
+
display: flex; align-items: center; gap: 18px;
|
| 55 |
+
padding: 18px 22px;
|
| 56 |
+
background: linear-gradient(180deg, rgba(0,0,0,0.35), rgba(0,0,0,0));
|
| 57 |
+
border-bottom: 1px solid var(--border);
|
| 58 |
+
backdrop-filter: blur(4px);
|
| 59 |
+
}
|
| 60 |
+
.brand {
|
| 61 |
+
font-family: "Share Tech Mono", monospace;
|
| 62 |
+
color: var(--matrix);
|
| 63 |
+
text-shadow: 0 0 8px rgba(0,255,156,0.4);
|
| 64 |
+
letter-spacing: 0.04em;
|
| 65 |
+
font-size: 18px;
|
| 66 |
+
}
|
| 67 |
+
nav a {
|
| 68 |
+
color: var(--muted);
|
| 69 |
+
text-decoration: none;
|
| 70 |
+
margin-right: 16px;
|
| 71 |
+
transition: color .15s ease, text-shadow .15s ease;
|
| 72 |
+
}
|
| 73 |
+
nav a:hover {
|
| 74 |
+
color: var(--matrix);
|
| 75 |
+
text-shadow: 0 0 8px rgba(0,255,156,0.4);
|
| 76 |
+
}
|
| 77 |
+
.wrap { position: relative; z-index: 2; max-width: 980px; margin: 0 auto; padding: 26px 22px 60px; }
|
| 78 |
+
.card {
|
| 79 |
+
background: var(--card);
|
| 80 |
+
border: 1px solid var(--border);
|
| 81 |
+
border-radius: 16px;
|
| 82 |
+
box-shadow: 0 0 0 1px rgba(0,255,156,0.06), 0 8px 30px rgba(0,0,0,0.35);
|
| 83 |
+
padding: 20px;
|
| 84 |
+
}
|
| 85 |
+
h2, h3, h4 { font-family: "Share Tech Mono", monospace; color: var(--matrix); letter-spacing: .02em; }
|
| 86 |
+
p { color: var(--text); opacity: 0.95; }
|
| 87 |
+
input, textarea {
|
| 88 |
+
width: 100%;
|
| 89 |
+
color: var(--text);
|
| 90 |
+
background: #020a04;
|
| 91 |
+
border: 1px solid var(--border);
|
| 92 |
+
border-radius: 12px;
|
| 93 |
+
padding: 12px 12px;
|
| 94 |
+
font-size: 14px;
|
| 95 |
+
font-family: "Share Tech Mono", monospace;
|
| 96 |
+
outline: none;
|
| 97 |
+
transition: border-color .15s ease, box-shadow .15s ease;
|
| 98 |
+
}
|
| 99 |
+
input:focus, textarea:focus {
|
| 100 |
+
border-color: var(--matrix);
|
| 101 |
+
box-shadow: 0 0 0 3px rgba(0,255,156,0.08), 0 0 12px rgba(0,255,156,0.25) inset;
|
| 102 |
+
}
|
| 103 |
+
button {
|
| 104 |
+
background: linear-gradient(180deg, #00ff9c, #00c97e);
|
| 105 |
+
color: #002f1b;
|
| 106 |
+
border: 0;
|
| 107 |
+
padding: 10px 16px;
|
| 108 |
+
border-radius: 12px;
|
| 109 |
+
font-weight: 700;
|
| 110 |
+
font-family: "Share Tech Mono", monospace;
|
| 111 |
+
letter-spacing: 0.03em;
|
| 112 |
+
cursor: pointer;
|
| 113 |
+
box-shadow: 0 6px 20px rgba(0,255,156,0.25);
|
| 114 |
+
transition: transform .05s ease, box-shadow .15s ease, filter .15s ease;
|
| 115 |
+
}
|
| 116 |
+
button:hover { filter: brightness(1.05); box-shadow: 0 10px 30px rgba(0,255,156,0.35); }
|
| 117 |
+
button:active { transform: translateY(1px); }
|
| 118 |
+
pre, code {
|
| 119 |
+
font-family: "Share Tech Mono", monospace;
|
| 120 |
+
background: #020a04;
|
| 121 |
+
border: 1px solid var(--border);
|
| 122 |
+
border-radius: 12px;
|
| 123 |
+
}
|
| 124 |
+
pre { padding: 12px; overflow: auto; }
|
| 125 |
+
@keyframes glow {
|
| 126 |
+
0%, 100% { text-shadow: 0 0 10px rgba(0,255,156,0.12); }
|
| 127 |
+
50% { text-shadow: 0 0 14px rgba(0,255,156,0.28); }
|
| 128 |
+
}
|
| 129 |
+
h3 { animation: glow 3.5s ease-in-out infinite; }
|
| 130 |
+
</style>
|
| 131 |
+
</head>
|
| 132 |
+
<body>
|
| 133 |
+
<canvas id="code-rain"></canvas>
|
| 134 |
+
|
| 135 |
+
<header>
|
| 136 |
+
<div class="brand">MATRIX-AI</div>
|
| 137 |
+
<nav>
|
| 138 |
+
<a href="/validator">Validator</a>
|
| 139 |
+
<a href="/home">Info</a>
|
| 140 |
+
<a href="https://github.com/agent-matrix/matrix-ai" target="_blank" rel="noreferrer" title="Give me a star on GitHub!">GitHub</a>
|
| 141 |
+
</nav>
|
| 142 |
+
</header>
|
| 143 |
+
|
| 144 |
+
<div class="wrap">
|
| 145 |
+
{% block body %}{% endblock %}
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<script>
|
| 149 |
+
(function () {
|
| 150 |
+
const c = document.getElementById('code-rain');
|
| 151 |
+
if (!c) return;
|
| 152 |
+
const ctx = c.getContext('2d', { alpha: true });
|
| 153 |
+
let w, h, cols, drops;
|
| 154 |
+
const fontSize = 20;
|
| 155 |
+
const charSet = 'アァカサタナハマヤラワ0123456789アイウエオアイウエオ01';
|
| 156 |
+
|
| 157 |
+
function resize() {
|
| 158 |
+
w = c.width = window.innerWidth;
|
| 159 |
+
h = c.height = window.innerHeight;
|
| 160 |
+
cols = Math.floor(w / fontSize);
|
| 161 |
+
drops = Array(cols).fill(0).map(() => Math.floor(Math.random() * -50));
|
| 162 |
+
ctx.font = fontSize + "px 'Share Tech Mono', monospace";
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function draw() {
|
| 166 |
+
ctx.fillStyle = 'rgba(2, 10, 4, 0.10)';
|
| 167 |
+
ctx.fillRect(0, 0, w, h);
|
| 168 |
+
for (let i = 0; i < cols; i++) {
|
| 169 |
+
const x = i * fontSize;
|
| 170 |
+
const y = drops[i] * fontSize;
|
| 171 |
+
const ch = charSet[Math.floor(Math.random() * charSet.length)];
|
| 172 |
+
ctx.shadowColor = 'rgba(0,255,156,0.35)';
|
| 173 |
+
ctx.shadowBlur = 8;
|
| 174 |
+
ctx.fillStyle = '#00ff9c';
|
| 175 |
+
ctx.fillText(ch, x, y);
|
| 176 |
+
if (y > h && Math.random() > 0.975) drops[i] = 0;
|
| 177 |
+
else drops[i]++;
|
| 178 |
+
}
|
| 179 |
+
setTimeout(draw, 70);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
window.addEventListener('resize', resize);
|
| 183 |
+
resize();
|
| 184 |
+
draw();
|
| 185 |
+
})();
|
| 186 |
+
</script>
|
| 187 |
+
</body>
|
| 188 |
+
</html>
|
app/templates/home.html
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block body %}
|
| 3 |
+
<div class="card" style="padding:28px">
|
| 4 |
+
<div class="hero" style="display:grid; gap:14px;">
|
| 5 |
+
<div class="badge">MatrixHub • A2A Verification</div>
|
| 6 |
+
<h1 style="margin:0">MatrixHub</h1>
|
| 7 |
+
<p class="lede">
|
| 8 |
+
MatrixHub uses <strong>A2A agents</strong> and <strong>MCP servers</strong> to deliver
|
| 9 |
+
plug-and-play building blocks for <em>multi-agent applications</em>. This instance ships
|
| 10 |
+
a compact <strong>A2A Validator</strong> UI so you can quickly connect to agents,
|
| 11 |
+
inspect their <em>Agent Card</em>, and validate protocol compliance.
|
| 12 |
+
</p>
|
| 13 |
+
|
| 14 |
+
<div class="cta">
|
| 15 |
+
<a class="btn" href="/validator">Open Validator</a>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<div class="meta">
|
| 19 |
+
<span class="chip">Product: MatrixHub Validator</span>
|
| 20 |
+
<span class="chip">Version: {{ request.app.version }}</span>
|
| 21 |
+
<span class="chip">Endpoints: /validator · /agent-card · /healthz · /readyz</span>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div class="grid">
|
| 27 |
+
<div class="card feature">
|
| 28 |
+
<div class="icon">🤝</div>
|
| 29 |
+
<h3>What is A2A?</h3>
|
| 30 |
+
<p>
|
| 31 |
+
<strong>A2A (Agent-to-Agent)</strong> is a simple, open protocol that lets independently
|
| 32 |
+
built AI agents talk to each other over the web. It standardizes three things:
|
| 33 |
+
</p>
|
| 34 |
+
<div class="list">
|
| 35 |
+
<div>• <strong>Discovery</strong> — every agent publishes an <em>Agent Card</em> (JSON) with
|
| 36 |
+
name/description/version, capabilities (e.g. streaming), skills, and default IO modes.
|
| 37 |
+
Think of it like “openapi.json” for agents.</div>
|
| 38 |
+
<div>• <strong>Messaging</strong> — structured events over HTTP/JSON-RPC (and optionally websockets):
|
| 39 |
+
<code>message</code>, <code>task</code>, <code>status-update</code>, <code>artifact-update</code>;
|
| 40 |
+
supports streaming; carries metadata for auth/tenancy.</div>
|
| 41 |
+
<div>• <strong>Interop rules</strong> — content types, IDs, correlation, and validation
|
| 42 |
+
so different frameworks can compose cleanly.</div>
|
| 43 |
+
</div>
|
| 44 |
+
<p style="margin-top:8px">
|
| 45 |
+
<strong>Why it matters:</strong> interoperability, discoverability, portability, safety/compliance,
|
| 46 |
+
and composability (fan-out, tools-as-agents) with consistent IDs and streaming.
|
| 47 |
+
</p>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<div class="card feature">
|
| 51 |
+
<div class="icon">🧭</div>
|
| 52 |
+
<h3>Where MatrixHub fits</h3>
|
| 53 |
+
<p>MatrixHub is the “registry + utilities” layer for A2A:</p>
|
| 54 |
+
<div class="list">
|
| 55 |
+
<div>• <strong>Directory/Catalog:</strong> publish & discover A2A-compatible agents by skill, version, trust.</div>
|
| 56 |
+
<div>• <strong>Validation & QA:</strong> lint Agent Cards and live endpoints (what this Validator UI does).</div>
|
| 57 |
+
<div>• <strong>Routing/Relay:</strong> optional proxy for CORS, auth, rate-limits, observability.</div>
|
| 58 |
+
<div>• <strong>Dev Experience:</strong> templates, SDKs, and “try it” UIs for quick local testing.</div>
|
| 59 |
+
<div>• <strong>Governance:</strong> versions, deprecation, and optional signing (provenance & trust).</div>
|
| 60 |
+
</div>
|
| 61 |
+
<p style="margin-top:8px">
|
| 62 |
+
Think of MatrixHub as the connective tissue that makes A2A practical at team/org scale.
|
| 63 |
+
</p>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div class="card feature">
|
| 67 |
+
<div class="icon">🧩</div>
|
| 68 |
+
<h3>A2A on MCP servers</h3>
|
| 69 |
+
<p>
|
| 70 |
+
MCP is great for wiring <em>tools</em> to <em>clients</em> (e.g., editor ↔ tools). A2A complements MCP for
|
| 71 |
+
agent↔agent federation across domains:
|
| 72 |
+
</p>
|
| 73 |
+
<div class="list">
|
| 74 |
+
<div>• <strong>Expose MCP tools as A2A skills:</strong> publish an Agent Card; other agents can discover/call them.</div>
|
| 75 |
+
<div>• <strong>Cross-ecosystem calls:</strong> LLM agents, workflows, or other MCP servers invoke via A2A without speaking MCP.</div>
|
| 76 |
+
<div>• <strong>Uniform messaging:</strong> map MCP tool calls to A2A <code>message</code>/<code>task</code> and use A2A streaming.</div>
|
| 77 |
+
<div>• <strong>Catalog integration:</strong> MatrixHub indexes your MCP server by its Agent Card for discovery & testing.</div>
|
| 78 |
+
</div>
|
| 79 |
+
<p style="margin-top:8px">
|
| 80 |
+
Net effect: MCP stays your local tool bridge; A2A turns it into an internet-addressable agent other agents can compose with.
|
| 81 |
+
</p>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div class="card arch">
|
| 86 |
+
<h3 style="margin-top:0">What this program does now</h3>
|
| 87 |
+
<div class="diagram">
|
| 88 |
+
<div class="lane">
|
| 89 |
+
<div class="box user">You</div>
|
| 90 |
+
<div class="arrow">/validator</div>
|
| 91 |
+
<div class="box ai">MatrixHub Validator</div>
|
| 92 |
+
<div class="arrow">/agent-card</div>
|
| 93 |
+
<div class="box svc">Target Agent (A2A)</div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<div class="quick">
|
| 98 |
+
<p><strong>Summary:</strong> This instance exposes a lightweight A2A Validator:</p>
|
| 99 |
+
<div class="list" style="margin-top:6px">
|
| 100 |
+
<div>• <strong>Validator UI</strong> at <code>/validator</code> — connect to an agent URL, fetch & validate its Agent Card.</div>
|
| 101 |
+
<div>• <strong>Alias</strong> <code>POST /agent-card</code> → <code>/validator/agent-card</code> (frontend default).</div>
|
| 102 |
+
<div>• <strong>Health</strong> endpoints: <code>/healthz</code>, <code>/readyz</code>.</div>
|
| 103 |
+
<div>• <strong>Socket.IO</strong> mounted at <code>/socket.io</code> (used for debug logs & future streaming).</div>
|
| 104 |
+
<div>• RAG/Chat/Plan routes are disabled here to keep the focus on A2A verification.</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<details style="margin-top:10px">
|
| 108 |
+
<summary>Quick start (local)</summary>
|
| 109 |
+
<pre><code># Run the service
|
| 110 |
+
uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 111 |
+
|
| 112 |
+
# Open the Validator UI
|
| 113 |
+
# → http://localhost:7860/validator
|
| 114 |
+
|
| 115 |
+
# (Optional) Resolve an Agent Card directly (server alias)
|
| 116 |
+
curl -s -X POST http://localhost:7860/agent-card \
|
| 117 |
+
-H 'content-type: application/json' \
|
| 118 |
+
-d '{"url":"https://example.com/.well-known/agent.json","sid":"debug"}' | jq</code></pre>
|
| 119 |
+
</details>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<style>
|
| 124 |
+
.hero .badge{
|
| 125 |
+
display:inline-block; font-size:12px; letter-spacing:.06em;
|
| 126 |
+
color:#002f1b; background:linear-gradient(180deg,#00ff9c,#00c97e);
|
| 127 |
+
border-radius:999px; padding:6px 10px; font-weight:700;
|
| 128 |
+
box-shadow:0 6px 24px rgba(0,255,156,.25);
|
| 129 |
+
}
|
| 130 |
+
h1 { font-family:"Share Tech Mono", monospace; color:var(--matrix); letter-spacing:.03em; }
|
| 131 |
+
.lede { font-size:16px; opacity:.95; max-width:64ch; }
|
| 132 |
+
.cta { display:flex; gap:10px; margin-top:4px; flex-wrap:wrap; }
|
| 133 |
+
.btn {
|
| 134 |
+
display:inline-block; text-decoration:none; padding:10px 14px; border-radius:12px;
|
| 135 |
+
font-weight:700; font-family:"Share Tech Mono", monospace; letter-spacing:.03em;
|
| 136 |
+
background:linear-gradient(180deg,#00ff9c,#00c97e); color:#032215;
|
| 137 |
+
box-shadow:0 6px 20px rgba(0,255,156,.25);
|
| 138 |
+
}
|
| 139 |
+
.btn.ghost { background:#0b1a12; color:var(--muted); border:1px solid var(--border); box-shadow:none; }
|
| 140 |
+
.btn:hover { filter:brightness(1.05); }
|
| 141 |
+
.meta { display:flex; gap:10px; flex-wrap:wrap; margin-top:6px; }
|
| 142 |
+
.chip {
|
| 143 |
+
font-size:12px; border:1px solid var(--border); border-radius:999px;
|
| 144 |
+
padding:4px 10px; background:#061006a6;
|
| 145 |
+
}
|
| 146 |
+
.grid {
|
| 147 |
+
margin:22px auto; display:grid; gap:16px;
|
| 148 |
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
| 149 |
+
}
|
| 150 |
+
.feature .icon { font-size:22px; margin-bottom:4px; }
|
| 151 |
+
.feature .list { margin-top:6px; opacity:.9; font-size:14px; display:grid; gap:4px; }
|
| 152 |
+
.arch { margin-top:18px; }
|
| 153 |
+
.diagram { margin-top:8px; display:grid; gap:8px; }
|
| 154 |
+
.lane { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
| 155 |
+
.box {
|
| 156 |
+
padding:8px 10px; border-radius:10px; border:1px solid var(--border);
|
| 157 |
+
background:#020a04; font-family:"Share Tech Mono", monospace; font-size:13px;
|
| 158 |
+
}
|
| 159 |
+
.svc { box-shadow:0 0 0 1px rgba(0,255,156,.08); }
|
| 160 |
+
.ai { box-shadow:0 0 0 1px rgba(0,255,156,.12); }
|
| 161 |
+
.user{ box-shadow:0 0 0 1px rgba(0,255,156,.08); }
|
| 162 |
+
.arrow { opacity:.7; font-family:"Share Tech Mono", monospace; }
|
| 163 |
+
.quick { margin-top:8px; display:grid; gap:8px; }
|
| 164 |
+
details { border:1px solid var(--border); border-radius:12px; padding:8px 10px; background:#06100680; }
|
| 165 |
+
details summary { cursor:pointer; user-select:none; color:var(--muted); }
|
| 166 |
+
pre { margin:8px 0 0; }
|
| 167 |
+
</style>
|
| 168 |
+
{% endblock %}
|
app/templates/validator.html
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block body %}
|
| 3 |
+
|
| 4 |
+
<div class="card">
|
| 5 |
+
<h3>A2A Validator /// MATRIX NODE</h3>
|
| 6 |
+
|
| 7 |
+
<div class="fieldset">
|
| 8 |
+
<div class="fieldset-head" id="connection-header">ESTABLISH CONNECTION</div>
|
| 9 |
+
<div class="fieldset-body" style="display:grid; gap:12px; margin-top:12px;">
|
| 10 |
+
<input type="text" id="agent-card-url" placeholder="Enter Agent Card URL" />
|
| 11 |
+
<div class="fieldset inner">
|
| 12 |
+
<div class="fieldset-head http-headers-header" id="http-headers-toggle">
|
| 13 |
+
<span class="chev">►</span> HTTP HEADERS
|
| 14 |
+
</div>
|
| 15 |
+
<div class="fieldset-body http-headers-content" id="http-headers-content" style="display:none;">
|
| 16 |
+
<div id="headers-list"></div>
|
| 17 |
+
<button id="add-header-btn" type="button">+ Add Header</button>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
<div class="actions-row">
|
| 21 |
+
<button id="connect-btn" type="button">Connect</button>
|
| 22 |
+
<a class="link" href="https://a2a-protocol.org/latest/" target="_blank" rel="noreferrer">Protocol Docs ↗</a>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<div id="agent-card-section" class="fieldset" style="margin-top:16px;">
|
| 28 |
+
<h2 class="collapsible-header">
|
| 29 |
+
<span class="toggle-icon">▼</span> AGENT CARD
|
| 30 |
+
</h2>
|
| 31 |
+
<div class="collapsible-content">
|
| 32 |
+
<div id="validation-errors">
|
| 33 |
+
<p class="muted">Awaiting connection to view agent card...</p>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="agent-card-display">
|
| 36 |
+
<pre><code id="agent-card-content" class="language-json"></code></pre>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div id="chat-container" class="fieldset" style="margin-top:16px;">
|
| 42 |
+
<div class="fieldset-head chat-header">CHAT TERMINAL</div>
|
| 43 |
+
<div class="fieldset-body">
|
| 44 |
+
<p class="muted chat-info">Agent messages marked with ✅ (compliant) or ⚠️ (non-compliant). Click to view raw JSON.</p>
|
| 45 |
+
<div id="chat-messages" class="chat-list">
|
| 46 |
+
<p class="muted placeholder-text">Messages will appear here.</p>
|
| 47 |
+
</div>
|
| 48 |
+
<div class="fieldset inner message-metadata-container">
|
| 49 |
+
<div class="fieldset-head message-metadata-header" id="message-metadata-toggle">
|
| 50 |
+
<span class="chev">►</span> MESSAGE METADATA
|
| 51 |
+
</div>
|
| 52 |
+
<div class="fieldset-body message-metadata-content" id="message-metadata-content" style="display:none;">
|
| 53 |
+
<div id="metadata-list"></div>
|
| 54 |
+
<button id="add-metadata-btn" type="button">+ Add Metadata</button>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="row chat-input-container" style="margin-top:10px;">
|
| 58 |
+
<input type="text" id="chat-input" placeholder="> Type a message…" disabled />
|
| 59 |
+
<button id="send-btn" type="button" disabled>Send</button>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div id="loader" class="loader-overlay" aria-hidden="true" style="display:none;">
|
| 66 |
+
<div class="loader-wrap">
|
| 67 |
+
<div class="loader-spinner"></div>
|
| 68 |
+
<div class="loader-text">CONNECTING…</div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div id="debug-console" class="hidden">
|
| 73 |
+
<div id="debug-handle">
|
| 74 |
+
<span>Debug Console</span>
|
| 75 |
+
<div class="debug-controls">
|
| 76 |
+
<button id="clear-console-btn">Clear</button>
|
| 77 |
+
<button id="toggle-console-btn">Show</button>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
<div id="debug-content"></div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div id="json-modal" class="modal-overlay hidden">
|
| 84 |
+
<div class="modal-content">
|
| 85 |
+
<span class="modal-close-btn">×</span>
|
| 86 |
+
<h3>Raw JSON</h3>
|
| 87 |
+
<pre id="modal-json-content"></pre>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<style>
|
| 92 |
+
:root {
|
| 93 |
+
--matrix: #00ff9c;
|
| 94 |
+
--border: rgba(0, 255, 156, 0.2);
|
| 95 |
+
--background: #020a04;
|
| 96 |
+
--dark-surface: #0a140a;
|
| 97 |
+
--muted-2: #9aa29a;
|
| 98 |
+
}
|
| 99 |
+
body {
|
| 100 |
+
font-family: "Share Tech Mono", monospace;
|
| 101 |
+
background-color: var(--background);
|
| 102 |
+
color: #e0e0e0;
|
| 103 |
+
margin: 0;
|
| 104 |
+
padding: 2rem;
|
| 105 |
+
}
|
| 106 |
+
body::before{
|
| 107 |
+
content:"";
|
| 108 |
+
position: fixed;
|
| 109 |
+
inset:0;
|
| 110 |
+
z-index:-1;
|
| 111 |
+
background:
|
| 112 |
+
radial-gradient(800px 500px at 50% -20%, rgba(0,255,156,0.08), transparent 40%),
|
| 113 |
+
linear-gradient(180deg, rgba(0,0,0,0.72), rgba(0,0,0,0.65));
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.card { position: relative; z-index: 1; max-width: 800px; margin: 0 auto; }
|
| 117 |
+
h1, h2, h3, .fieldset-head { text-transform: uppercase; }
|
| 118 |
+
h1 { color: var(--matrix); }
|
| 119 |
+
h2 { font-size: 1em; margin: 0; }
|
| 120 |
+
.muted { color: var(--muted-2, #9aa29a); }
|
| 121 |
+
.row { display:flex; gap:8px; align-items: center; }
|
| 122 |
+
.row input[type="text"] { flex:1; }
|
| 123 |
+
.actions-row { display: flex; gap: 12px; align-items: center; }
|
| 124 |
+
|
| 125 |
+
.fieldset { border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: rgba(2,10,4,0.35); backdrop-filter: blur(2px); }
|
| 126 |
+
.fieldset.inner { background: rgba(2,10,4,0.25); border-color: rgba(128, 128, 128, 0.3); }
|
| 127 |
+
.fieldset-head, .collapsible-header { font-weight: 600; letter-spacing: .02em; cursor: pointer; user-select:none; display:flex; align-items:center; gap:6px; color: var(--matrix); text-shadow: 0 0 5px var(--matrix); padding: 5px; }
|
| 128 |
+
.fieldset-body, .collapsible-content { padding-top: 4px; }
|
| 129 |
+
.fieldset-body pre { background: #020a04; border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin: 8px 0 0 0; }
|
| 130 |
+
.chev, .toggle-icon { font-family: ui-monospace, "Share Tech Mono", monospace; }
|
| 131 |
+
|
| 132 |
+
input, button { font-family: inherit; }
|
| 133 |
+
input[type="text"] { background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 8px; padding: 10px; color: #e0e0e0; }
|
| 134 |
+
button { background: transparent; border: 1px solid var(--matrix); color: var(--matrix); padding: 10px 15px; border-radius: 8px; cursor: pointer; transition: all 0.2s; }
|
| 135 |
+
button:hover:not(:disabled) { background: var(--matrix); color: var(--background); box-shadow: 0 0 10px var(--matrix); }
|
| 136 |
+
button:disabled { border-color: var(--muted-2); color: var(--muted-2); cursor: not-allowed; }
|
| 137 |
+
|
| 138 |
+
.chat-list { height: 300px; overflow-y: auto; background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 8px; padding: 10px; }
|
| 139 |
+
.chat-info { font-size: 0.9em; }
|
| 140 |
+
|
| 141 |
+
/* Debug & Modal Styling (adapted for Matrix theme) */
|
| 142 |
+
#debug-console { position: fixed; bottom: 0; left: 0; right: 0; height: 200px; background: var(--dark-surface); border-top: 2px solid var(--matrix); display: flex; flex-direction: column; z-index: 100; }
|
| 143 |
+
#debug-console.hidden { display: none; }
|
| 144 |
+
#debug-handle { background: #000; padding: 5px 10px; cursor: ns-resize; display: flex; justify-content: space-between; align-items: center; text-transform: uppercase; letter-spacing: 0.1em; }
|
| 145 |
+
#debug-content { flex-grow: 1; overflow-y: auto; padding: 10px; font-size: 0.9em; }
|
| 146 |
+
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(5px); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
| 147 |
+
.modal-overlay.hidden { display: none; }
|
| 148 |
+
.modal-content { position: relative; background: var(--dark-surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; max-width: 800px; width: 90%; max-height: 80vh; display: flex; flex-direction: column; }
|
| 149 |
+
.modal-close-btn { position: absolute; top: 10px; right: 15px; font-size: 24px; cursor: pointer; color: var(--matrix); }
|
| 150 |
+
|
| 151 |
+
/* Loader Styling (same as before) */
|
| 152 |
+
.loader-overlay { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: center; backdrop-filter: blur(3px); background: radial-gradient(800px 500px at 50% -20%, rgba(0,255,156,0.08), transparent 40%), linear-gradient(180deg, rgba(0,0,0,0.72), rgba(0,0,0,0.65)); }
|
| 153 |
+
.loader-wrap { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 22px 26px; border-radius: 16px; border: 1px solid var(--border); background: rgba(6,16,6,0.75); box-shadow: 0 10px 40px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,255,156,0.06); }
|
| 154 |
+
.loader-spinner { width: 64px; height: 64px; border-radius: 50%; border: 3px solid rgba(0,255,156,0.15); border-top-color: var(--matrix); border-right-color: var(--matrix); box-shadow: 0 0 18px rgba(0,255,156,0.35); animation: spin 0.9s linear infinite; }
|
| 155 |
+
.loader-text { font-family: "Share Tech Mono", monospace; letter-spacing: 0.08em; color: var(--matrix); text-shadow: 0 0 8px rgba(0,255,156,0.35); opacity: 0.95; }
|
| 156 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 157 |
+
</style>
|
| 158 |
+
|
| 159 |
+
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
| 160 |
+
<script src="/static/script.js"></script>
|
| 161 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
| 162 |
+
|
| 163 |
+
<script>
|
| 164 |
+
// Raining Code Effect
|
| 165 |
+
const canvas = document.getElementById('matrix-canvas');
|
| 166 |
+
if (canvas) {
|
| 167 |
+
const ctx = canvas.getContext('2d');
|
| 168 |
+
canvas.width = window.innerWidth;
|
| 169 |
+
canvas.height = window.innerHeight;
|
| 170 |
+
const alphabet = 'アァカサタナハマヤャラワガザダバパイィキシチニヒミリヰギジヂビピウゥクスツヌフムユュルグズブプエェケセテネヘメレヱゲゼデベペオォコソトノホモヨョロヲゴゾドボポヴッンABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
| 171 |
+
const fontSize = 16;
|
| 172 |
+
const columns = canvas.width / fontSize;
|
| 173 |
+
const rainDrops = Array.from({ length: columns }).fill(1);
|
| 174 |
+
const draw = () => {
|
| 175 |
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
|
| 176 |
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 177 |
+
ctx.fillStyle = '#0F0';
|
| 178 |
+
ctx.font = fontSize + 'px monospace';
|
| 179 |
+
for (let i = 0; i < rainDrops.length; i++) {
|
| 180 |
+
const text = alphabet.charAt(Math.floor(Math.random() * alphabet.length));
|
| 181 |
+
ctx.fillText(text, i * fontSize, rainDrops[i] * fontSize);
|
| 182 |
+
if (rainDrops[i] * fontSize > canvas.height && Math.random() > 0.975) {
|
| 183 |
+
rainDrops[i] = 0;
|
| 184 |
+
}
|
| 185 |
+
rainDrops[i]++;
|
| 186 |
+
}
|
| 187 |
+
};
|
| 188 |
+
setInterval(draw, 33);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
// The main script.js will handle its own toggles now that the HTML is correct.
|
| 192 |
+
// We only need to add listeners for the fieldsets that script.js doesn't know about.
|
| 193 |
+
(function () {
|
| 194 |
+
function setupToggle(toggleEl, contentEl) {
|
| 195 |
+
if (!toggleEl || !contentEl) return;
|
| 196 |
+
toggleEl.addEventListener('click', () => {
|
| 197 |
+
const isHidden = contentEl.style.display === 'none';
|
| 198 |
+
contentEl.style.display = isHidden ? 'block' : 'none';
|
| 199 |
+
const chev = toggleEl.querySelector('.chev');
|
| 200 |
+
if (chev) chev.textContent = isHidden ? '▼' : '►';
|
| 201 |
+
});
|
| 202 |
+
}
|
| 203 |
+
// script.js handles '.collapsible-header', so we only set up our custom fieldsets.
|
| 204 |
+
setupToggle(document.getElementById('http-headers-toggle'), document.getElementById('http-headers-content'));
|
| 205 |
+
setupToggle(document.getElementById('message-metadata-toggle'), document.getElementById('message-metadata-content'));
|
| 206 |
+
})();
|
| 207 |
+
</script>
|
| 208 |
+
{% endblock %}
|
app/ui.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/ui.py
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Request, Form
|
| 4 |
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 5 |
+
from fastapi.templating import Jinja2Templates
|
| 6 |
+
import httpx
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
templates = Jinja2Templates(directory="app/templates")
|
| 12 |
+
|
| 13 |
+
# Tabs to render in the UI. "Info" is now the active tab for the /home route.
|
| 14 |
+
NAV_TABS = [
|
| 15 |
+
{"href": "/validator", "label": "Validator"},
|
| 16 |
+
{"href": "/home", "label": "Info"},
|
| 17 |
+
]
|
| 18 |
+
templates.env.globals["NAV_TABS"] = NAV_TABS
|
| 19 |
+
|
| 20 |
+
def _self_base_url() -> str:
|
| 21 |
+
port = os.getenv("PORT", "7860")
|
| 22 |
+
return f"http://127.0.0.1:{port}"
|
| 23 |
+
|
| 24 |
+
@router.get("/", include_in_schema=False)
|
| 25 |
+
async def root_redirect():
|
| 26 |
+
# Default to the Validator page
|
| 27 |
+
return RedirectResponse(url="/validator", status_code=302)
|
| 28 |
+
|
| 29 |
+
@router.get("/home", response_class=HTMLResponse, include_in_schema=False)
|
| 30 |
+
async def home_page(request: Request):
|
| 31 |
+
"""
|
| 32 |
+
FIX: This route now correctly serves the home.html template
|
| 33 |
+
instead of getting caught in a redirect loop.
|
| 34 |
+
"""
|
| 35 |
+
return templates.TemplateResponse(
|
| 36 |
+
"home.html",
|
| 37 |
+
# Pass the active tab name to the template
|
| 38 |
+
{"request": request, "tabs": NAV_TABS, "active": "home"},
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# The /chat and /dev routes are not needed based on your last request,
|
| 42 |
+
# so they have been removed to simplify the file.
|
| 43 |
+
# If you need them back, you can uncomment them.
|
| 44 |
+
|
| 45 |
+
# @router.get("/chat", response_class=HTMLResponse)
|
| 46 |
+
# async def chat_get(request: Request):
|
| 47 |
+
# return templates.TemplateResponse(
|
| 48 |
+
# "chat.html",
|
| 49 |
+
# {"request": request, "answer": None, "tabs": NAV_TABS, "active": "chat"},
|
| 50 |
+
# )
|
| 51 |
+
|
| 52 |
+
# @router.get("/dev", response_class=HTMLResponse)
|
| 53 |
+
# async def dev_get(request: Request):
|
| 54 |
+
# # ... dev page logic ...
|
| 55 |
+
# pass
|
app/validators.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/validators.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
from typing import Any, Iterable, Mapping, Sequence
|
| 6 |
+
from urllib.parse import urlparse
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# -----------------------------
|
| 10 |
+
# Helpers
|
| 11 |
+
# -----------------------------
|
| 12 |
+
|
| 13 |
+
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.\-]+)?$")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _is_non_empty_str(val: Any) -> bool:
|
| 17 |
+
return isinstance(val, str) and val.strip() != ""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _is_list_of_str(val: Any) -> bool:
|
| 21 |
+
return isinstance(val, list) and all(isinstance(x, str) for x in val)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _as_mapping(val: Any) -> Mapping[str, Any] | None:
|
| 25 |
+
return val if isinstance(val, Mapping) else None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _as_sequence(val: Any) -> Sequence[Any] | None:
|
| 29 |
+
return val if isinstance(val, Sequence) and not isinstance(val, (str, bytes)) else None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# -----------------------------
|
| 33 |
+
# Agent Card Validation
|
| 34 |
+
# -----------------------------
|
| 35 |
+
|
| 36 |
+
_REQUIRED_AGENT_CARD_FIELDS = frozenset(
|
| 37 |
+
[
|
| 38 |
+
"name",
|
| 39 |
+
"description",
|
| 40 |
+
"url",
|
| 41 |
+
"version",
|
| 42 |
+
"capabilities",
|
| 43 |
+
"defaultInputModes",
|
| 44 |
+
"defaultOutputModes",
|
| 45 |
+
"skills",
|
| 46 |
+
]
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def validate_agent_card(card_data: dict[str, Any]) -> list[str]:
|
| 51 |
+
"""
|
| 52 |
+
Validate the structure and fields of an agent card.
|
| 53 |
+
|
| 54 |
+
Contract (non-exhaustive, pragmatic checks):
|
| 55 |
+
- Required top-level fields must exist.
|
| 56 |
+
- url must be absolute (http/https) with a host.
|
| 57 |
+
- version should be semver-like (e.g., 1.2.3 or 1.2.3-alpha).
|
| 58 |
+
- capabilities must be an object/dict.
|
| 59 |
+
- defaultInputModes/defaultOutputModes must be non-empty arrays of strings.
|
| 60 |
+
- skills must be a non-empty array (objects or strings permitted); if objects, "name" should be string.
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
A list of human-readable error strings. Empty list means "looks valid".
|
| 64 |
+
"""
|
| 65 |
+
errors: list[str] = []
|
| 66 |
+
data = card_data or {}
|
| 67 |
+
|
| 68 |
+
# Presence of required fields
|
| 69 |
+
for field in _REQUIRED_AGENT_CARD_FIELDS:
|
| 70 |
+
if field not in data:
|
| 71 |
+
errors.append(f"Required field is missing: '{field}'.")
|
| 72 |
+
|
| 73 |
+
# Type/format checks (guard with `in` to avoid KeyErrors)
|
| 74 |
+
# name
|
| 75 |
+
if "name" in data and not _is_non_empty_str(data["name"]):
|
| 76 |
+
errors.append("Field 'name' must be a non-empty string.")
|
| 77 |
+
|
| 78 |
+
# description
|
| 79 |
+
if "description" in data and not _is_non_empty_str(data["description"]):
|
| 80 |
+
errors.append("Field 'description' must be a non-empty string.")
|
| 81 |
+
|
| 82 |
+
# url
|
| 83 |
+
if "url" in data:
|
| 84 |
+
url_val = data["url"]
|
| 85 |
+
if not _is_non_empty_str(url_val):
|
| 86 |
+
errors.append("Field 'url' must be a non-empty string.")
|
| 87 |
+
else:
|
| 88 |
+
parsed = urlparse(url_val)
|
| 89 |
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
| 90 |
+
errors.append(
|
| 91 |
+
"Field 'url' must be an absolute URL with http(s) scheme and host."
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# version (soft semver check; adjust if your ecosystem allows non-semver)
|
| 95 |
+
if "version" in data:
|
| 96 |
+
ver = data["version"]
|
| 97 |
+
if not _is_non_empty_str(ver):
|
| 98 |
+
errors.append("Field 'version' must be a non-empty string.")
|
| 99 |
+
elif not _SEMVER_RE.match(ver):
|
| 100 |
+
errors.append(
|
| 101 |
+
"Field 'version' should be semver-like (e.g., '1.2.3' or '1.2.3-alpha')."
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# capabilities
|
| 105 |
+
if "capabilities" in data:
|
| 106 |
+
if not isinstance(data["capabilities"], dict):
|
| 107 |
+
errors.append("Field 'capabilities' must be an object.")
|
| 108 |
+
else:
|
| 109 |
+
# Optional: sanity checks for common capability fields
|
| 110 |
+
caps = data["capabilities"]
|
| 111 |
+
if "streaming" in caps and not isinstance(caps["streaming"], bool):
|
| 112 |
+
errors.append("Field 'capabilities.streaming' must be a boolean if present.")
|
| 113 |
+
|
| 114 |
+
# defaultInputModes / defaultOutputModes
|
| 115 |
+
for field in ("defaultInputModes", "defaultOutputModes"):
|
| 116 |
+
if field in data:
|
| 117 |
+
modes = data[field]
|
| 118 |
+
if not _is_list_of_str(modes):
|
| 119 |
+
errors.append(f"Field '{field}' must be an array of strings.")
|
| 120 |
+
elif len(modes) == 0:
|
| 121 |
+
errors.append(f"Field '{field}' must not be empty.")
|
| 122 |
+
|
| 123 |
+
# skills
|
| 124 |
+
if "skills" in data:
|
| 125 |
+
skills = _as_sequence(data["skills"])
|
| 126 |
+
if skills is None:
|
| 127 |
+
errors.append("Field 'skills' must be an array.")
|
| 128 |
+
elif len(skills) == 0:
|
| 129 |
+
errors.append(
|
| 130 |
+
"Field 'skills' must not be empty. Agent must have at least one skill if it performs actions."
|
| 131 |
+
)
|
| 132 |
+
else:
|
| 133 |
+
# If entries are objects, check they have a name
|
| 134 |
+
for i, s in enumerate(skills):
|
| 135 |
+
if isinstance(s, Mapping):
|
| 136 |
+
if not _is_non_empty_str(s.get("name")):
|
| 137 |
+
errors.append(f"skills[{i}].name is required and must be a non-empty string.")
|
| 138 |
+
elif not isinstance(s, str):
|
| 139 |
+
errors.append(
|
| 140 |
+
f"skills[{i}] must be either an object with 'name' or a string; found: {type(s).__name__}"
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
return errors
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# -----------------------------
|
| 147 |
+
# Agent Message/Event Validation
|
| 148 |
+
# -----------------------------
|
| 149 |
+
|
| 150 |
+
def _validate_task(data: dict[str, Any]) -> list[str]:
|
| 151 |
+
errors: list[str] = []
|
| 152 |
+
if "id" not in data:
|
| 153 |
+
errors.append("Task object missing required field: 'id'.")
|
| 154 |
+
status = _as_mapping(data.get("status"))
|
| 155 |
+
if status is None or "state" not in status:
|
| 156 |
+
errors.append("Task object missing required field: 'status.state'.")
|
| 157 |
+
return errors
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _validate_status_update(data: dict[str, Any]) -> list[str]:
|
| 161 |
+
errors: list[str] = []
|
| 162 |
+
status = _as_mapping(data.get("status"))
|
| 163 |
+
if status is None or "state" not in status:
|
| 164 |
+
errors.append("StatusUpdate object missing required field: 'status.state'.")
|
| 165 |
+
return errors
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _validate_artifact_update(data: dict[str, Any]) -> list[str]:
|
| 169 |
+
errors: list[str] = []
|
| 170 |
+
artifact = _as_mapping(data.get("artifact"))
|
| 171 |
+
if artifact is None:
|
| 172 |
+
errors.append("ArtifactUpdate object missing required field: 'artifact'.")
|
| 173 |
+
return errors
|
| 174 |
+
|
| 175 |
+
parts = artifact.get("parts")
|
| 176 |
+
if not isinstance(parts, list) or len(parts) == 0:
|
| 177 |
+
errors.append("Artifact object must have a non-empty 'parts' array.")
|
| 178 |
+
return errors
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _validate_message(data: dict[str, Any]) -> list[str]:
|
| 182 |
+
errors: list[str] = []
|
| 183 |
+
parts = data.get("parts")
|
| 184 |
+
if not isinstance(parts, list) or len(parts) == 0:
|
| 185 |
+
errors.append("Message object must have a non-empty 'parts' array.")
|
| 186 |
+
role = data.get("role")
|
| 187 |
+
if role != "agent":
|
| 188 |
+
errors.append("Message from agent must have 'role' set to 'agent'.")
|
| 189 |
+
# Optional: check text presence in at least one part if parts are objects
|
| 190 |
+
# (Leave relaxed to avoid false negatives if parts are other media-types)
|
| 191 |
+
return errors
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
_KIND_VALIDATORS: dict[str, callable[[dict[str, Any]], list[str]]] = {
|
| 195 |
+
"task": _validate_task,
|
| 196 |
+
"status-update": _validate_status_update,
|
| 197 |
+
"artifact-update": _validate_artifact_update,
|
| 198 |
+
"message": _validate_message,
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def validate_message(data: dict[str, Any]) -> list[str]:
|
| 203 |
+
"""
|
| 204 |
+
Validate an incoming event/message coming from the agent according to its 'kind'.
|
| 205 |
+
|
| 206 |
+
Expected kinds: 'task', 'status-update', 'artifact-update', 'message'
|
| 207 |
+
Returns:
|
| 208 |
+
A list of human-readable error strings. Empty list means "looks valid".
|
| 209 |
+
"""
|
| 210 |
+
if not isinstance(data, Mapping):
|
| 211 |
+
return ["Response from agent must be an object."]
|
| 212 |
+
if "kind" not in data:
|
| 213 |
+
return ["Response from agent is missing required 'kind' field."]
|
| 214 |
+
|
| 215 |
+
kind = str(data.get("kind"))
|
| 216 |
+
validator = _KIND_VALIDATORS.get(kind)
|
| 217 |
+
if validator:
|
| 218 |
+
return validator(dict(data))
|
| 219 |
+
|
| 220 |
+
return [f"Unknown message kind received: '{kind}'."]
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
__all__ = [
|
| 224 |
+
"validate_agent_card",
|
| 225 |
+
"validate_message",
|
| 226 |
+
]
|
assets/2025-10-05-00-49-00.png
ADDED
|
Git LFS Details
|
configs/.env.example
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# === API Keys (DO NOT COMMIT REAL KEYS) ===
|
| 2 |
+
GROQ_API_KEY=your_groq_key_here
|
| 3 |
+
GOOGLE_API_KEY=your_google_gemini_key_here
|
| 4 |
+
HF_TOKEN=your_huggingface_token_here
|
| 5 |
+
|
| 6 |
+
# === Provider order ===
|
| 7 |
+
# Comma-separated cascade, first working provider wins.
|
| 8 |
+
# Options: groq, gemini, router
|
| 9 |
+
PROVIDER_ORDER=groq,gemini,router
|
| 10 |
+
|
| 11 |
+
# === Provider-default models (override if needed) ===
|
| 12 |
+
GROQ_MODEL=llama-3.1-8b-instant
|
| 13 |
+
GEMINI_MODEL=gemini-2.5-flash
|
| 14 |
+
|
| 15 |
+
# === Logging ===
|
| 16 |
+
LOG_LEVEL=INFO
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# For local development only. Use Space Secrets in production.
|
| 21 |
+
ADMIN_TOKEN="a-secure-admin-token-for-index-refresh"
|
| 22 |
+
|
| 23 |
+
# --- Optional Overrides ---
|
| 24 |
+
# MODEL_NAME="mistralai/Mistral-7B-Instruct-v0.2"
|
| 25 |
+
# INDEX_DATASET="your-username/matrix-ai-index"
|
| 26 |
+
# RATE_LIMITS="120" # requests per minute
|
configs/rag_sources.yaml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Where to pull documentation from when building the RAG knowledge base.
|
| 2 |
+
# You can add/remove repos here; the builder will respect these sources.
|
| 3 |
+
|
| 4 |
+
github:
|
| 5 |
+
# 1) Explicit repos (stable)
|
| 6 |
+
repos:
|
| 7 |
+
- owner: agent-matrix
|
| 8 |
+
name: matrix-cli
|
| 9 |
+
branch: master
|
| 10 |
+
docs_paths: ["docs"] # folders to harvest (recursive)
|
| 11 |
+
include_readme: true
|
| 12 |
+
- owner: agent-matrix
|
| 13 |
+
name: matrix-python-sdk
|
| 14 |
+
branch: master
|
| 15 |
+
docs_paths: ["docs"]
|
| 16 |
+
include_readme: true
|
| 17 |
+
- owner: agent-matrix
|
| 18 |
+
name: matrixlink
|
| 19 |
+
branch: master
|
| 20 |
+
docs_paths: ["docs"]
|
| 21 |
+
include_readme: true
|
| 22 |
+
- owner: agent-matrix
|
| 23 |
+
name: matrix-hub
|
| 24 |
+
branch: master
|
| 25 |
+
docs_paths: ["docs"]
|
| 26 |
+
include_readme: true
|
| 27 |
+
|
| 28 |
+
# 2) Optionally scan an entire org for repos (README + docs/ if present)
|
| 29 |
+
# Comment out if you want only the explicit list above.
|
| 30 |
+
orgs:
|
| 31 |
+
- agent-matrix
|
| 32 |
+
|
| 33 |
+
# Local content in THIS repo (optional but recommended)
|
| 34 |
+
local:
|
| 35 |
+
paths:
|
| 36 |
+
- docs # everything under /docs
|
| 37 |
+
- README.md # root readme
|
| 38 |
+
glob: "**/*.md" # or "**/*.{md,mdx,txt}"
|
| 39 |
+
|
| 40 |
+
# Extra public URLs to pull (optional)
|
| 41 |
+
urls: []
|
configs/settings.yaml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
model:
|
| 2 |
+
# HF router defaults (used at the last step)
|
| 3 |
+
name: "HuggingFaceH4/zephyr-7b-beta"
|
| 4 |
+
fallback: "mistralai/Mistral-7B-Instruct-v0.2"
|
| 5 |
+
provider: "featherless-ai"
|
| 6 |
+
max_new_tokens: 256
|
| 7 |
+
temperature: 0.2
|
| 8 |
+
|
| 9 |
+
# Provider-specific defaults (free-tier friendly)
|
| 10 |
+
groq_model: "llama-3.1-8b-instant"
|
| 11 |
+
gemini_model: "gemini-2.5-flash"
|
| 12 |
+
|
| 13 |
+
# Try providers in this order
|
| 14 |
+
provider_order:
|
| 15 |
+
- groq
|
| 16 |
+
- gemini
|
| 17 |
+
- router
|
| 18 |
+
|
| 19 |
+
# Switch to the multi-provider path
|
| 20 |
+
chat_backend: "multi"
|
| 21 |
+
chat_stream: true
|
| 22 |
+
|
| 23 |
+
limits:
|
| 24 |
+
rate_per_min: 60
|
| 25 |
+
cache_size: 256
|
| 26 |
+
|
| 27 |
+
rag:
|
| 28 |
+
index_dataset: ""
|
| 29 |
+
top_k: 4
|
| 30 |
+
|
| 31 |
+
matrixhub:
|
| 32 |
+
base_url: "https://api.matrixhub.io"
|
| 33 |
+
|
| 34 |
+
security:
|
| 35 |
+
admin_token: ""
|
pyproject.toml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61.0"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "a2a-validator"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Agent validator Service for Matrix EcoSystem"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.11"
|
| 11 |
+
license = { text = "Apache-2.0" }
|
| 12 |
+
|
| 13 |
+
dependencies = [
|
| 14 |
+
"fastapi==0.111.0",
|
| 15 |
+
"groq>=0.32.0",
|
| 16 |
+
"uvicorn[standard]==0.29.0",
|
| 17 |
+
"httpx==0.28.1",
|
| 18 |
+
"pydantic>=2.7.1",
|
| 19 |
+
"python-json-logger==2.0.7",
|
| 20 |
+
"cachetools==5.3.3",
|
| 21 |
+
"huggingface-hub==0.23.0",
|
| 22 |
+
"sentence-transformers==2.7.0",
|
| 23 |
+
"faiss-cpu==1.8.0",
|
| 24 |
+
"numpy==1.26.4",
|
| 25 |
+
"orjson==3.10.3",
|
| 26 |
+
"pyyaml==6.0.1",
|
| 27 |
+
"tenacity==8.2.3",
|
| 28 |
+
"python-dotenv==1.0.1",
|
| 29 |
+
"google-genai>=1.39.1",
|
| 30 |
+
|
| 31 |
+
# --- Added for a2a-validator ---
|
| 32 |
+
"a2a-sdk[http-server]>=0.3.0",
|
| 33 |
+
"httpx-sse>=0.4.0",
|
| 34 |
+
"jwcrypto>=1.5.6",
|
| 35 |
+
"pyjwt>=2.10.1",
|
| 36 |
+
"sse-starlette>=2.2.1",
|
| 37 |
+
"typing-extensions>=4.12.2",
|
| 38 |
+
# FIX: Ensure all standard websocket dependencies are included
|
| 39 |
+
"python-socketio[asyncio_standard]>=5.11.0",
|
| 40 |
+
"jinja2>=3.1.2",
|
| 41 |
+
"bleach>=6.2.0"
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
[tool.ruff]
|
| 45 |
+
line-length = 100
|
| 46 |
+
target-version = "py311"
|
| 47 |
+
|
| 48 |
+
[tool.ruff.lint]
|
| 49 |
+
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
|
| 50 |
+
ignore = ["E501"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn[standard]==0.29.0
|
| 3 |
+
httpx>=0.28.1
|
| 4 |
+
pydantic>=2.7.1
|
| 5 |
+
python-json-logger==2.0.7
|
| 6 |
+
cachetools==5.3.3
|
| 7 |
+
huggingface-hub==0.23.0
|
| 8 |
+
#sentence-transformers==2.7.0
|
| 9 |
+
#faiss-cpu==1.8.0
|
| 10 |
+
numpy==1.26.4
|
| 11 |
+
orjson==3.10.3
|
| 12 |
+
pyyaml==6.0.1
|
| 13 |
+
tenacity==8.2.3
|
| 14 |
+
jinja2==3.1.4
|
| 15 |
+
a2a-sdk[http-server]>=0.3.0
|
| 16 |
+
python-socketio[asyncio_standard]>=5.11.0
|
| 17 |
+
bleach>=6.2.0
|
| 18 |
+
jinja2>=3.1.2
|
| 19 |
+
|
| 20 |
+
# Dev (optional)
|
| 21 |
+
pytest
|
| 22 |
+
ruff
|
| 23 |
+
mypy
|
| 24 |
+
pytest-asyncio
|
| 25 |
+
|
| 26 |
+
# Additional libraries for extended functionality
|
| 27 |
+
#groq>=0.32.0
|
| 28 |
+
python-dotenv==1.0.1
|
| 29 |
+
#google-genai>=1.39.1
|
| 30 |
+
|
| 31 |
+
requests>=2.32.0
|
| 32 |
+
#beautifulsoup4>=4.12.3 # only used if you later add generic HTML URLs
|
| 33 |
+
PyYAML>=6.0.1
|