Compare commits
236 Commits
8adeefe4a9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f023243e | ||
|
|
6bdaeed6d4 | ||
|
|
d5a8f84703 | ||
|
|
c4b5fcc067 | ||
|
|
5753899eb3 | ||
|
|
9c3fa80d22 | ||
|
|
43c3ff860c | ||
|
|
3e4b1e1597 | ||
|
|
3a5664c4da | ||
|
|
d139a63e64 | ||
|
|
8a2ae6eb75 | ||
|
|
992cf71fa1 | ||
|
|
54356ba81a | ||
|
|
e9d7c56d5b | ||
|
|
2ebc2756bf | ||
|
|
606a88c805 | ||
|
|
eaada4bc57 | ||
|
|
d321005044 | ||
|
|
59353308a2 | ||
|
|
6b0756a55f | ||
|
|
4d8a606cd6 | ||
|
|
23f7de6cbf | ||
|
|
a12c4bea64 | ||
|
|
e5b03c6601 | ||
|
|
3eb78d343a | ||
|
|
a0f6d9f702 | ||
|
|
bb681aa1f3 | ||
|
|
bc560145a4 | ||
|
|
5311c99d69 | ||
|
|
545b31d32f | ||
|
|
8417a9f542 | ||
|
|
9a5ed0e94a | ||
|
|
50d2dc579a | ||
|
|
f9553a6a1a | ||
|
|
ee730aa31c | ||
|
|
0264a4b5b4 | ||
|
|
332f77389d | ||
|
|
d4ff79f326 | ||
|
|
93212600eb | ||
|
|
73966b3a7b | ||
|
|
1f40ce3df3 | ||
|
|
f17098aa58 | ||
|
|
8094333e3b | ||
|
|
0122f3b250 | ||
|
|
dc4cad2baa | ||
|
|
e725b7f19c | ||
|
|
84a8998e59 | ||
|
|
bc743adef3 | ||
|
|
ded8b39ccb | ||
|
|
ba444a514f | ||
|
|
aa965da69d | ||
|
|
1b04ee1c4c | ||
|
|
103f225f54 | ||
|
|
e42dedaba1 | ||
|
|
607e127f59 | ||
|
|
6d33ba5742 | ||
|
|
08a4fa3577 | ||
|
|
d660a961fb | ||
|
|
669d22e71f | ||
|
|
88e91a5900 | ||
|
|
1986b0d945 | ||
|
|
24b5b71b0f | ||
|
|
8b3495455b | ||
|
|
3b74a330a3 | ||
|
|
8158716e23 | ||
|
|
0cda750ff0 | ||
|
|
81e990ab72 | ||
|
|
47c6a4bb73 | ||
|
|
96c2e1099a | ||
|
|
729d833edb | ||
|
|
304bbe1fd4 | ||
|
|
3d69f8501f | ||
|
|
4d04f4e7af | ||
|
|
3131112952 | ||
|
|
a2f67af13e | ||
|
|
0cde1f8990 | ||
|
|
a6674a1e76 | ||
|
|
127d603e7d | ||
|
|
3f17619e0c | ||
|
|
59ba76c74a | ||
|
|
35372c6661 | ||
|
|
38653fa365 | ||
|
|
c28e99b714 | ||
|
|
43432534d8 | ||
|
|
cce19e4c40 | ||
|
|
b8915a29c0 | ||
|
|
4199feb681 | ||
|
|
0fac8b615f | ||
|
|
a3e5295915 | ||
|
|
1f4681f486 | ||
|
|
09a66c72cb | ||
|
|
0d525fa64c | ||
|
|
470f343b29 | ||
|
|
9f7b8b46a3 | ||
|
|
792741709a | ||
|
|
5747e85acf | ||
|
|
8b952c9a26 | ||
| 336fee9d93 | |||
|
|
25724c354f | ||
|
|
e124e4bbcb | ||
|
|
f60cebadb8 | ||
|
|
1cbf3fee44 | ||
|
|
87da5df91b | ||
|
|
75d5c178e1 | ||
|
|
b9826a1985 | ||
|
|
0f8bc4071a | ||
|
|
cb36d78fa2 | ||
|
|
8e2477587f | ||
|
|
67b81a1bd8 | ||
|
|
9c24a852e7 | ||
|
|
95956afbc6 | ||
|
|
c73178b65d | ||
|
|
8c2f301d85 | ||
|
|
4717ee6086 | ||
|
|
513ff909f9 | ||
|
|
92198549f6 | ||
|
|
59d3bf0f00 | ||
|
|
04f0951b3d | ||
|
|
8887cf5a27 | ||
|
|
34457f9c3e | ||
|
|
e12b140508 | ||
|
|
18d716bc6b | ||
|
|
74d488adfa | ||
|
|
31052d0b98 | ||
|
|
20cb60e247 | ||
|
|
3130c42d76 | ||
|
|
6fc5e66ea1 | ||
|
|
27dd2f0a0d | ||
|
|
faa39e6c06 | ||
|
|
d060f89d30 | ||
|
|
0d6327a990 | ||
|
|
15006a05a7 | ||
|
|
0c74b4ab4a | ||
|
|
ca691f3ee0 | ||
|
|
92444e7eae | ||
|
|
7989f3a159 | ||
|
|
4c59941ec6 | ||
|
|
678f64d772 | ||
|
|
e080105f9f | ||
|
|
64cc76c970 | ||
|
|
99e90798d2 | ||
|
|
064eeb614f | ||
|
|
b383244a29 | ||
|
|
e384318046 | ||
|
|
8a4a777be7 | ||
|
|
04cd6d0f81 | ||
|
|
d4d5d40569 | ||
|
|
cbb98f4469 | ||
|
|
7d32eae74e | ||
|
|
b1a9c8a194 | ||
|
|
2dcc72102d | ||
|
|
df49103f23 | ||
|
|
e7bef0883d | ||
|
|
e1e515ecae | ||
|
|
0e861d8fa6 | ||
|
|
d0e946cf47 | ||
|
|
50b1c3f9a9 | ||
|
|
575f093c74 | ||
|
|
5b388d08c0 | ||
|
|
88ff04bef8 | ||
|
|
1f15699013 | ||
|
|
222ba0bfdc | ||
|
|
2e57702638 | ||
|
|
5fe3b201d9 | ||
|
|
f6f787ff38 | ||
|
|
2908dda024 | ||
|
|
e701fa01da | ||
|
|
f28d7e6d16 | ||
|
|
b183b0bd5e | ||
|
|
8f65661809 | ||
|
|
002bf4f756 | ||
|
|
f8b25a7ccc | ||
|
|
d7e98a58b9 | ||
|
|
57957d11a0 | ||
|
|
2574bc81d1 | ||
|
|
54ffef66d3 | ||
|
|
d460ee0fe7 | ||
|
|
9472813739 | ||
|
|
dc007f948a | ||
|
|
9db663e81f | ||
|
|
813ac81950 | ||
|
|
9902a3b968 | ||
|
|
29df4eee3b | ||
|
|
5106d286a1 | ||
|
|
64ec27949f | ||
|
|
8814fe7dfa | ||
|
|
9b97f456cf | ||
|
|
9d90bf5299 | ||
|
|
35a3783481 | ||
|
|
4414ffb34c | ||
|
|
55e0591a5e | ||
|
|
68f663f2f4 | ||
|
|
212c935308 | ||
|
|
763afa0ee2 | ||
|
|
72ea05ae0d | ||
|
|
02f54ea208 | ||
|
|
94adb82acc | ||
|
|
1bab7c22d7 | ||
|
|
891cecb4a8 | ||
|
|
3a3000cacd | ||
|
|
e9735f1606 | ||
|
|
7066707492 | ||
|
|
11435468f1 | ||
|
|
6793b6f832 | ||
|
|
7a3feb14a0 | ||
|
|
1d5d009bc7 | ||
|
|
8691385a8e | ||
|
|
5b4e2b5d84 | ||
|
|
4f3556a38b | ||
|
|
45abd36430 | ||
|
|
ea339d883a | ||
|
|
244b3a58f7 | ||
|
|
344ac126b3 | ||
|
|
d8d0415bf4 | ||
|
|
511337df95 | ||
|
|
c9cc0b0641 | ||
|
|
68a448a551 | ||
|
|
4b1dae7ebc | ||
|
|
7209c75ad8 | ||
|
|
98f68c47b0 | ||
|
|
056d6dbe22 | ||
|
|
910c959829 | ||
|
|
c99a423f6a | ||
|
|
e21f0d82e9 | ||
|
|
ad16358e71 | ||
|
|
fad583ee7c | ||
|
|
af98acceb3 | ||
|
|
3bc7668f6c | ||
|
|
32a43cf6bb | ||
|
|
dbf6c36c65 | ||
|
|
f36e1bbee7 | ||
|
|
1cbf790fcf | ||
|
|
f9f91380ad | ||
|
|
bac3f00ae4 | ||
|
|
b0fef46fc6 | ||
|
|
8b39f48dec |
160
.codex/skills/agent-change-log/SKILL.md
Normal file
160
.codex/skills/agent-change-log/SKILL.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
name: agent-change-log
|
||||
description: Use when working in X-Financial after bug fixes that need a split dev log, when maintaining document/development/YYYY-MM-DD/dev-logs/bugs, or when generating the daily 17:00 combined work-logs.med from same-day feature docs and bug logs.
|
||||
---
|
||||
|
||||
# Agent Change Log
|
||||
|
||||
## Overview
|
||||
|
||||
This skill keeps split development logs for X-Financial. Bug fixes are recorded under the same-day `dev-logs/bugs` folder. Daily summaries combine same-day feature documents and bug records into one `work-logs.med`.
|
||||
|
||||
The log should sound like a careful teammate writing for tomorrow's teammate: concrete, warm, and honest.
|
||||
|
||||
## When To Use
|
||||
|
||||
- After fixing a bug.
|
||||
- When a post-commit hook detects a bug-like commit.
|
||||
- At the daily 17:00 summary pass.
|
||||
- Before updating the log in a branch where other agents may have pushed commits.
|
||||
- After verification, so the entry can include what was actually checked.
|
||||
- When a failed attempt changed files, generated artifacts, or revealed a risk worth preserving.
|
||||
|
||||
Do not create legacy `document/work-log/YYYY-MM-DD.md` entries for new work.
|
||||
|
||||
## Log Location
|
||||
|
||||
Use the same date root as development documents:
|
||||
|
||||
```text
|
||||
document/development/YYYY-MM-DD/
|
||||
├── feature/<feature-point>/CONCEPT.md + TODO.md
|
||||
├── dev-logs/bugs/<bug-slug>.md
|
||||
└── work-logs.med
|
||||
```
|
||||
|
||||
If the date folder exists, reuse it. If not, create it.
|
||||
|
||||
## Bug Log Structure
|
||||
|
||||
For bug fixes, create or update one file per bug:
|
||||
|
||||
```text
|
||||
document/development/YYYY-MM-DD/dev-logs/bugs/<bug-slug>.md
|
||||
```
|
||||
|
||||
The file must contain `## 修复记录` and timestamped bullets similar to the old `当日工作内容`.
|
||||
Do not add `遗留问题` or `TODO` sections to bug logs.
|
||||
|
||||
Use the helper when possible:
|
||||
|
||||
```bash
|
||||
python3 tools/agent-change-log/update_change_log.py \
|
||||
--kind bug \
|
||||
--bug-title "<bug 名称>" \
|
||||
--bug-slug <bug-slug>
|
||||
```
|
||||
|
||||
## Required Git Check
|
||||
|
||||
Before writing or updating a bug log, manually check Git for upstream and local-ahead commits from other agents.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
date '+%Y-%m-%d %H:%M:%S %Z'
|
||||
git fetch --all --prune
|
||||
git status -sb
|
||||
git rev-parse --abbrev-ref --symbolic-full-name @{u}
|
||||
git log --oneline --decorate --max-count=20 HEAD..@{u}
|
||||
git log --oneline --decorate --max-count=20 @{u}..HEAD
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Treat `git fetch --all --prune` as the default safe "pull check"; it updates remote refs without merging into a dirty worktree.
|
||||
- Treat `HEAD..@{u}` as upstream commits not yet in local history.
|
||||
- Treat `@{u}..HEAD` as local commits not yet in upstream history; these may also come from another agent working in the same checkout.
|
||||
- If the worktree is clean and the branch is only behind upstream, `git pull --ff-only` may be used to fast-forward before analysis.
|
||||
- If the worktree is dirty, diverged, or likely to conflict, do not merge/rebase automatically. Record the upstream commits from `HEAD..@{u}` in the bug fix entry.
|
||||
- If there is no upstream branch, record that fact in the bug fix entry and continue with local-only logging.
|
||||
- When `HEAD..@{u}` has commits, summarize those commits in the bug fix entry before describing local edits. Mention commit hash, subject, and inferred impact.
|
||||
- When `@{u}..HEAD` has commits that were not created in the current task, summarize them too, because another local agent may have committed without pushing yet.
|
||||
- When no upstream or local-ahead commits exist, still record "Git 提交检查:未发现 upstream 新提交或本地 ahead 新提交" in the work entry.
|
||||
|
||||
## Bug Entry Rules
|
||||
|
||||
1. Get the current local time first:
|
||||
```bash
|
||||
date '+%Y-%m-%d %H:%M:%S %Z'
|
||||
```
|
||||
2. Run the required Git check and capture whether upstream or local-ahead has new commits.
|
||||
3. Ensure `document/development/YYYY-MM-DD/dev-logs/bugs/` exists.
|
||||
4. Create or update one `<bug-slug>.md` file for the specific bug.
|
||||
5. Append a new timestamped bullet under `## 修复记录`.
|
||||
6. Mention Git commits, changed files or modules, the operation, the intent, and the verification result.
|
||||
7. Do not add leftover issue or TODO sections.
|
||||
|
||||
## Daily Summary
|
||||
|
||||
At 17:00 every day, generate the combined work log:
|
||||
|
||||
```bash
|
||||
python3 tools/agent-change-log/update_change_log.py --kind summary
|
||||
```
|
||||
|
||||
This reads:
|
||||
|
||||
```text
|
||||
document/development/YYYY-MM-DD/feature/
|
||||
document/development/YYYY-MM-DD/dev-logs/bugs/
|
||||
```
|
||||
|
||||
Then writes:
|
||||
|
||||
```text
|
||||
document/development/YYYY-MM-DD/work-logs.med
|
||||
```
|
||||
|
||||
The summary should cover:
|
||||
|
||||
- Today's feature points from `feature/*/CONCEPT.md` and `TODO.md`.
|
||||
- Today's bug fixes from `dev-logs/bugs/*.md`.
|
||||
- A concise combined analysis of what changed that day.
|
||||
|
||||
## Entry Rules
|
||||
|
||||
- For each bug entry, append a new timestamped bullet under `## 修复记录`.
|
||||
- Mention Git commits, changed files or modules, the operation, the intent, and the verification result.
|
||||
- Do not write `遗留问题`.
|
||||
- Do not write `TODO`.
|
||||
- If the change is a feature rather than a bug, use the development document skill to keep `feature/<feature-point>/CONCEPT.md` and `TODO.md` current instead of writing a bug log.
|
||||
|
||||
## Writing Style
|
||||
|
||||
- Write in Simplified Chinese.
|
||||
- Be specific and a little human: "我把...", "这次先...", "还需要留意..." are good.
|
||||
- Keep the tone factual. Do not turn the log into a victory lap.
|
||||
- Prefer concise file names and module names in prose, but include enough context to find the change.
|
||||
- Work content should be detailed enough that a future agent can continue without asking "你到底改了啥?"
|
||||
|
||||
## Bug Entry Template
|
||||
|
||||
```markdown
|
||||
- HH:MM:记录 bug 修复:<bug 名称>。
|
||||
- Git 提交检查:<git fetch 后 HEAD..upstream 与 upstream..HEAD 的结果;没有就写未发现 upstream 或本地 ahead 新提交>。
|
||||
- 修改:<文件/模块>,<做了什么>。
|
||||
- 操作:<运行了什么命令、迁移了什么状态、重启了什么服务等>。
|
||||
- 验证:<测试/构建/检查结果;如果没跑,说明原因>。
|
||||
- 影响:<用户可见变化或工程边界变化>。
|
||||
```
|
||||
|
||||
## Final Response Checklist
|
||||
|
||||
Before saying work is complete:
|
||||
|
||||
- For bug fixes, today's bug log exists under `document/development/YYYY-MM-DD/dev-logs/bugs/`.
|
||||
- For non-bug feature work, relevant `feature/<feature-point>` documents are current.
|
||||
- Git check ran for bug logs, and upstream plus local-ahead commits were summarized or explicitly marked as absent.
|
||||
- No new legacy `document/work-log/YYYY-MM-DD.md` entry was created.
|
||||
- The final response mentions whether a bug log, feature document, or daily `work-logs.med` was updated.
|
||||
4
.codex/skills/agent-change-log/agents/openai.yaml
Normal file
4
.codex/skills/agent-change-log/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Agent Change Log"
|
||||
short_description: "Record bug logs and daily work summaries"
|
||||
default_prompt: "Use $agent-change-log after an X-Financial bug fix or at 17:00 to update document/development/<date>/dev-logs/bugs or work-logs.med."
|
||||
85
.codex/skills/git-checkpoint-commit/SKILL.md
Normal file
85
.codex/skills/git-checkpoint-commit/SKILL.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: git-checkpoint-commit
|
||||
description: Use when a coding task finishes a verified bug fix, key feature, risky refactor checkpoint, local backup commit, checkpoint commit, 提交备份, 本地提交, or when an active session has accumulated about five meaningful edit rounds and should create a scoped local git commit without pushing.
|
||||
---
|
||||
|
||||
# Git Checkpoint Commit
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill to keep a local git backup loop during active development. The goal is to commit verified, task-scoped progress at meaningful checkpoints without mixing unrelated user changes or pushing remotely.
|
||||
|
||||
## Trigger Rules
|
||||
|
||||
Create a local checkpoint commit when any condition is true:
|
||||
|
||||
- A bug fix is implemented and the targeted regression check passes.
|
||||
- A key feature slice is implemented and the smallest relevant verification passes.
|
||||
- A risky refactor reaches a behavior-preserving checkpoint.
|
||||
- The current session has reached about five meaningful edit rounds since the last commit.
|
||||
- The user asks for `提交`, `本地提交`, `备份`, `checkpoint`, `commit`, or `形成提交循环`.
|
||||
|
||||
Do not use this skill when the user explicitly says not to commit, when the change is exploratory and unverified, or when the only available commit would include unrelated dirty files.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Inspect the working tree with `git status --short`.
|
||||
2. Identify files or hunks owned by the current task.
|
||||
3. Run the smallest relevant verification first.
|
||||
- In X-Financial, run backend tests inside `x-financial-main` when backend code is involved.
|
||||
- Use targeted frontend tests/builds for web-only changes.
|
||||
4. Commit only the task-owned files.
|
||||
5. Report the commit hash and verification evidence.
|
||||
6. Reset the session edit counter to zero after a successful checkpoint.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Never commit unrelated user changes just to make the tree clean.
|
||||
- Never push as part of this skill.
|
||||
- Never rewrite history, amend old commits, or run destructive git commands.
|
||||
- If the same file contains unrelated hunks, split the staging carefully with `git add -p` or a narrower manual patch.
|
||||
- If the index already contains staged changes, inspect them first; do not mix them into a checkpoint unless they belong to the same current task.
|
||||
- If verification cannot run, say why in the commit body or final report and prefer a `chore(checkpoint)` message rather than a confident `fix` or `feat`.
|
||||
|
||||
## Commit Style
|
||||
|
||||
Use normal semantic messages for completed, verified work:
|
||||
|
||||
- `fix(workbench): keep application preview after draft save failure`
|
||||
- `feat(reimbursements): add attachment association job polling`
|
||||
- `refactor(claims): split draft flow serialization`
|
||||
|
||||
Use checkpoint messages for interim backup points:
|
||||
|
||||
- `chore(checkpoint): backup attachment association flow`
|
||||
- `chore(checkpoint): backup after five edit rounds`
|
||||
|
||||
Keep the subject concise. Add body lines only when the verification state or scope needs clarity.
|
||||
|
||||
## Helper Script
|
||||
|
||||
Use `scripts/checkpoint_commit.py` when a path-scoped local commit is enough:
|
||||
|
||||
```bash
|
||||
python3 .codex/skills/git-checkpoint-commit/scripts/checkpoint_commit.py \
|
||||
--message "chore(checkpoint): backup workbench AI flow" \
|
||||
web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js \
|
||||
web/tests/workbench-ai-mode-switch.test.mjs
|
||||
```
|
||||
|
||||
Preview before committing:
|
||||
|
||||
```bash
|
||||
python3 .codex/skills/git-checkpoint-commit/scripts/checkpoint_commit.py \
|
||||
--dry-run \
|
||||
web/src/utils/expenseApplicationPreview.js
|
||||
```
|
||||
|
||||
The script refuses to commit the whole tree unless `--allow-all` is passed. Use `--allow-all` only when the current task truly owns every dirty file.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Mistaking backup for verification. Verify first, then commit.
|
||||
- Staging `.` in a dirty worktree. Stage explicit paths or hunks.
|
||||
- Combining documentation cleanup, feature work, and unrelated local edits in one checkpoint.
|
||||
- Continuing indefinitely after multiple verified slices. Commit once the counter reaches five meaningful edit rounds.
|
||||
4
.codex/skills/git-checkpoint-commit/agents/openai.yaml
Normal file
4
.codex/skills/git-checkpoint-commit/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Git Checkpoint Commit"
|
||||
short_description: "Create local backup commits at safe milestones"
|
||||
default_prompt: "Use $git-checkpoint-commit to checkpoint verified task changes into a local git commit."
|
||||
127
.codex/skills/git-checkpoint-commit/scripts/checkpoint_commit.py
Normal file
127
.codex/skills/git-checkpoint-commit/scripts/checkpoint_commit.py
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a path-scoped local git checkpoint commit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_git(args: list[str], cwd: Path, *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=60,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
message = result.stderr.strip() or result.stdout.strip()
|
||||
raise SystemExit(f"git {' '.join(args)} failed: {message}")
|
||||
return result
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit("Not inside a git repository.")
|
||||
return Path(result.stdout.strip())
|
||||
|
||||
|
||||
def load_paths(args: argparse.Namespace) -> list[str]:
|
||||
paths = list(args.paths)
|
||||
if args.paths_from_file:
|
||||
raw_lines = Path(args.paths_from_file).read_text(encoding="utf-8").splitlines()
|
||||
paths.extend(line.strip() for line in raw_lines if line.strip() and not line.startswith("#"))
|
||||
return paths
|
||||
|
||||
|
||||
def ensure_clean_index(root: Path) -> None:
|
||||
staged = run_git(["diff", "--cached", "--name-only"], root).stdout.strip()
|
||||
if staged:
|
||||
raise SystemExit(
|
||||
"Refusing to continue because the index already has staged changes:\n"
|
||||
f"{staged}\n"
|
||||
"Commit or unstage them before running this checkpoint helper."
|
||||
)
|
||||
|
||||
|
||||
def status_for(root: Path, paths: list[str], allow_all: bool) -> str:
|
||||
command = ["status", "--short"]
|
||||
if not allow_all:
|
||||
command.extend(["--", *paths])
|
||||
return run_git(command, root).stdout.strip()
|
||||
|
||||
|
||||
def infer_message(paths: list[str], allow_all: bool) -> str:
|
||||
if allow_all or not paths:
|
||||
return "chore(checkpoint): backup current task changes"
|
||||
first_parts = [Path(item).parts[0] for item in paths if Path(item).parts]
|
||||
scope = first_parts[0] if first_parts else "task"
|
||||
return f"chore(checkpoint): backup {scope} changes"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("paths", nargs="*", help="Task-owned paths to stage and commit.")
|
||||
parser.add_argument("-m", "--message", help="Commit message subject/body.")
|
||||
parser.add_argument("--paths-from-file", help="Read additional pathspecs from a UTF-8 text file.")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Show matching changes without staging.")
|
||||
parser.add_argument("--allow-all", action="store_true", help="Allow committing all dirty files.")
|
||||
parser.add_argument("--no-verify", action="store_true", help="Pass --no-verify to git commit.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
root = repo_root()
|
||||
paths = load_paths(args)
|
||||
|
||||
if not paths and not args.allow_all:
|
||||
raise SystemExit("Pass explicit paths, or use --allow-all when the current task owns all changes.")
|
||||
|
||||
current_status = status_for(root, paths, args.allow_all)
|
||||
if not current_status:
|
||||
print("No matching changes to commit.")
|
||||
return 0
|
||||
|
||||
print(current_status)
|
||||
if args.dry_run:
|
||||
return 0
|
||||
|
||||
ensure_clean_index(root)
|
||||
|
||||
# 使用 -A 保留删除/重命名等变更,但只作用于明确传入的 pathspec。
|
||||
if args.allow_all:
|
||||
run_git(["add", "-A"], root)
|
||||
else:
|
||||
run_git(["add", "-A", "--", *paths], root)
|
||||
|
||||
staged_summary = run_git(["diff", "--cached", "--name-status"], root).stdout.strip()
|
||||
if not staged_summary:
|
||||
raise SystemExit("No staged changes after git add.")
|
||||
|
||||
message = args.message or infer_message(paths, args.allow_all)
|
||||
commit_command = ["commit"]
|
||||
if args.no_verify:
|
||||
commit_command.append("--no-verify")
|
||||
commit_command.extend(["-m", message])
|
||||
commit_result = run_git(commit_command, root)
|
||||
sys.stdout.write(commit_result.stdout)
|
||||
|
||||
commit_hash = run_git(["rev-parse", "--short", "HEAD"], root).stdout.strip()
|
||||
print(f"checkpoint_commit={commit_hash}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
125
.codex/skills/write-development-docs/SKILL.md
Normal file
125
.codex/skills/write-development-docs/SKILL.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: write-development-docs
|
||||
description: Use when working in X-Financial and the user asks to 落文档, 写开发文档, 沉淀方案, 补 concept/todo, or create/update planning documentation under document/development for a feature, refactor, page, algorithm, rule, or business capability.
|
||||
---
|
||||
|
||||
# Write Development Docs
|
||||
|
||||
## 目标
|
||||
|
||||
把一个功能、重构、算法、页面或业务能力沉淀为项目标准开发文档。
|
||||
默认落点固定为:
|
||||
|
||||
```text
|
||||
document/development/<YYYY-MM-DD>/feature/<具体功能点目录>/
|
||||
├── CONCEPT.md
|
||||
└── TODO.md
|
||||
```
|
||||
|
||||
如果用户说 `concept.md` / `todo.md`,也按仓库现有规范使用大写文件名
|
||||
`CONCEPT.md` 和 `TODO.md`。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 先读取当前日期,默认使用本地日期 `YYYY-MM-DD` 作为第一层目录。
|
||||
2. 先阅读 `document/development` 中 2-3 组相邻或同类样例。
|
||||
3. 再读取本次能力相关的代码、接口、页面、测试或历史文档。
|
||||
4. 创建或更新 `CONCEPT.md`,先写清业务边界,再写方案和验收。
|
||||
5. 创建或更新 `TODO.md`,每个任务都要回链到 `CONCEPT.md` 的章节。
|
||||
6. 已实现或已验证的 TODO 可以勾选 `[x]`,但必须写证据。
|
||||
7. 交付前检查两份文档互相一致,不额外创建 README、CHANGELOG、SUMMARY。
|
||||
|
||||
## 路径规则
|
||||
|
||||
- 第一层必须是日期目录,例如 `document/development/2026-06-25/`。
|
||||
- 第二层固定是 `feature/`,表示当天沉淀的功能点集合。
|
||||
- 第三层是具体功能点目录,每个独立功能点一个目录。
|
||||
- 每个具体功能点目录内只放 `CONCEPT.md` 和 `TODO.md` 两个核心文件。
|
||||
- 如果一次请求包含多个互不依赖的功能点,拆成多个兄弟目录:
|
||||
|
||||
```text
|
||||
document/development/2026-06-25/feature/
|
||||
├── receipt-folder-ocr/
|
||||
│ ├── CONCEPT.md
|
||||
│ └── TODO.md
|
||||
└── risk-review-nudge/
|
||||
├── CONCEPT.md
|
||||
└── TODO.md
|
||||
```
|
||||
|
||||
- 如果用户明确指定日期,使用用户指定日期;否则使用当前本地日期。
|
||||
- 如果是更新历史文档,先查找已有目录并原地更新,不自动迁移旧路径。
|
||||
|
||||
## 目录命名
|
||||
|
||||
- 优先复用用户指定目录名或已有目录名。
|
||||
- 新增英文目录用小写 kebab-case,例如 `receipt-folder`。
|
||||
- 新增中文目录可直接用清晰中文能力名,例如 `费用审批动态路由`。
|
||||
- 同一能力已有目录时更新原目录,不新建近义目录。
|
||||
|
||||
## CONCEPT.md 要求
|
||||
|
||||
可参考 `assets/CONCEPT.md` 模板。必须包含:
|
||||
|
||||
- 标题:`# <功能名> 概念文档`
|
||||
- `更新时间:YYYY-MM-DD`
|
||||
- `## 功能一句话`
|
||||
- `## 背景与问题`
|
||||
- `## 目标与非目标`
|
||||
- `## 用户与场景`
|
||||
- `## 功能能力`
|
||||
- `## 方案设计`
|
||||
- `## 算法与公式`
|
||||
- `## 测试方案`
|
||||
- `## 指标与验收`
|
||||
- `## 风险与开放问题`
|
||||
|
||||
写法要求:
|
||||
|
||||
- 先讲业务问题和边界,再讲技术方案。
|
||||
- 目标与非目标分开写,避免需求无限扩张。
|
||||
- 方案设计按前端、后端、算法/规则、数据、权限、降级策略分块;
|
||||
不涉及的块明确写“当前不涉及”。
|
||||
- 算法与公式必须明确“不涉及”或写出公式、变量说明和适用边界。
|
||||
- 验收标准必须可验证,不写空泛口号。
|
||||
|
||||
## TODO.md 要求
|
||||
|
||||
可参考 `assets/TODO.md` 模板。必须包含:
|
||||
|
||||
- 标题:`# <功能名> 开发 TODO`
|
||||
- `更新时间:YYYY-MM-DD`
|
||||
- `## 使用规则`
|
||||
- 分阶段 checklist
|
||||
|
||||
TODO 条目规则:
|
||||
|
||||
- 每条用 `- [ ]` 或 `- [x]`。
|
||||
- 每条必须包含 `[CONCEPT: <章节名>]`。
|
||||
- 已完成项必须补证据,格式为 `证据:<文件、接口、命令或验证结果>`。
|
||||
- 没有真实证据时不得勾选 `[x]`。
|
||||
|
||||
建议阶段:
|
||||
|
||||
- `## 1. 调研与边界`
|
||||
- `## 2. 契约与设计`
|
||||
- `## 3. 后端实现`
|
||||
- `## 4. 算法/规则实现`
|
||||
- `## 5. 前端实现`
|
||||
- `## 6. 测试与验证`
|
||||
- `## 7. 文档收尾`
|
||||
|
||||
## 更新既有文档
|
||||
|
||||
- 先读现有 `CONCEPT.md` 和 `TODO.md` 全文。
|
||||
- 新需求先补 `CONCEPT.md`,再补 `TODO.md`。
|
||||
- 实现变化时同步更新“非目标”“风险与开放问题”“本轮实现记录”。
|
||||
- 不删除历史证据;除非证据明显错误,才替换为新证据。
|
||||
|
||||
## 验收检查
|
||||
|
||||
- 新建文档路径符合 `document/development/<YYYY-MM-DD>/feature/<具体功能点目录>/`。
|
||||
- `CONCEPT.md` 和 `TODO.md` 都存在于同一个具体功能点目录。
|
||||
- TODO 的 `[CONCEPT: ...]` 都能在 CONCEPT 中找到对应章节或语义段落。
|
||||
- 已勾选项都有证据。
|
||||
- 文档没有遗留模板占位符,例如 `<功能名>`、`<YYYY-MM-DD>`、`待补充`。
|
||||
4
.codex/skills/write-development-docs/agents/openai.yaml
Normal file
4
.codex/skills/write-development-docs/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "开发文档落地"
|
||||
short_description: "按日期和功能点生成 CONCEPT/TODO"
|
||||
default_prompt: "Use $write-development-docs to create CONCEPT.md and TODO.md under document/development/<date>/feature/<feature-point> for an X-Financial feature."
|
||||
132
.codex/skills/write-development-docs/assets/CONCEPT.md
Normal file
132
.codex/skills/write-development-docs/assets/CONCEPT.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# <功能名> 概念文档
|
||||
|
||||
更新时间:<YYYY-MM-DD>
|
||||
|
||||
文档路径:document/development/<YYYY-MM-DD>/feature/<具体功能点目录>/CONCEPT.md
|
||||
|
||||
## 功能一句话
|
||||
|
||||
用一句话说明这个能力解决什么问题、服务谁、交付什么结果。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
- 当前现状:
|
||||
- 用户痛点:
|
||||
- 业务影响:
|
||||
- 为什么现在需要做:
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
### 目标
|
||||
|
||||
- [G1]
|
||||
- [G2]
|
||||
- [G3]
|
||||
|
||||
### 非目标
|
||||
|
||||
- [NG1] 本轮不做:
|
||||
- [NG2] 本轮不改变:
|
||||
- [NG3] 后续再评估:
|
||||
|
||||
## 用户与场景
|
||||
|
||||
- 目标用户:
|
||||
- 使用入口:
|
||||
- 核心场景:
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
- 异常场景:
|
||||
-
|
||||
|
||||
## 功能能力
|
||||
|
||||
- [C1] 输入能力:
|
||||
- [C2] 处理能力:
|
||||
- [C3] 输出能力:
|
||||
- [C4] 状态与权限:
|
||||
- [C5] 边界与降级:
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 前端
|
||||
|
||||
- 页面/组件:
|
||||
- 交互状态:
|
||||
- 展示规则:
|
||||
- 降级处理:
|
||||
|
||||
### 后端
|
||||
|
||||
- 接口/服务:
|
||||
- 权限与校验:
|
||||
- 持久化:
|
||||
- 降级处理:
|
||||
|
||||
### 算法与规则
|
||||
|
||||
- 输入:
|
||||
- 流程:
|
||||
- 输出:
|
||||
- 解释:
|
||||
|
||||
### 数据与契约
|
||||
|
||||
- 核心字段:
|
||||
- 状态枚举:
|
||||
- 兼容策略:
|
||||
- 版本/审计:
|
||||
|
||||
## 算法与公式
|
||||
|
||||
当前功能不涉及显式数学公式。
|
||||
|
||||
如涉及公式,使用如下格式:
|
||||
|
||||
```text
|
||||
metric = input_a + input_b
|
||||
```
|
||||
|
||||
变量说明:
|
||||
|
||||
- metric:
|
||||
- input_a:
|
||||
- input_b:
|
||||
|
||||
## 测试方案
|
||||
|
||||
后端:
|
||||
|
||||
-
|
||||
|
||||
前端:
|
||||
|
||||
-
|
||||
|
||||
集成:
|
||||
|
||||
-
|
||||
|
||||
手工验证:
|
||||
|
||||
-
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- [A1] 功能验收:
|
||||
- [A2] 性能指标:
|
||||
- [A3] 质量指标:
|
||||
- [A4] 安全/权限指标:
|
||||
- [A5] 可观测性:
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- 风险:
|
||||
- 已处理依赖:
|
||||
- 待确认:
|
||||
- 降级策略:
|
||||
|
||||
## 本轮实现记录
|
||||
|
||||
-
|
||||
73
.codex/skills/write-development-docs/assets/TODO.md
Normal file
73
.codex/skills/write-development-docs/assets/TODO.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# <功能名> 开发 TODO
|
||||
|
||||
更新时间:<YYYY-MM-DD>
|
||||
|
||||
文档路径:document/development/<YYYY-MM-DD>/feature/<具体功能点目录>/TODO.md
|
||||
|
||||
## 使用规则
|
||||
|
||||
- 每个 TODO 必须对应 `CONCEPT.md` 中的目标、能力、方案或验收点。
|
||||
- 只有完成并验证后,才能把 `[ ]` 改成 `[x]`。
|
||||
- 勾选时在任务后补充简短证据,例如文件、接口、命令或验证结果。
|
||||
- 如果需求发生变化,先更新 `CONCEPT.md`,再调整本 TODO。
|
||||
|
||||
## 1. 调研与边界
|
||||
|
||||
- [ ] [CONCEPT: 背景与问题] 阅读相关页面、接口、服务、测试和历史文档,记录当前实现事实。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 目标与非目标] 确认本轮开发范围,写清楚不做项。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 风险与开放问题] 标记无法立即确认的依赖、风险和假设。
|
||||
证据:
|
||||
|
||||
## 2. 契约与设计
|
||||
|
||||
- [ ] [CONCEPT: 功能能力] 定义输入、输出、状态、权限和边界条件。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 方案设计] 明确前端、后端、算法、数据的职责边界。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 算法与公式] 补全公式、变量解释或明确当前不涉及公式。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 指标与验收] 把验收标准转成可验证的检查点。
|
||||
证据:
|
||||
|
||||
## 3. 后端实现
|
||||
|
||||
- [ ] [CONCEPT: 后端] 新增或调整 schema、service、endpoint、权限和持久化逻辑。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 数据与契约] 保持响应结构、状态枚举和兼容策略清晰。
|
||||
证据:
|
||||
|
||||
## 4. 算法/规则实现
|
||||
|
||||
- [ ] [CONCEPT: 算法与规则] 实现核心处理流程、规则判断或计算逻辑。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 结果解释] 输出可读解释、证据链、贡献项或降级原因。
|
||||
证据:
|
||||
|
||||
## 5. 前端实现
|
||||
|
||||
- [ ] [CONCEPT: 前端] 新增或调整页面、组件、服务 API 和视图模型。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 前端] 实现加载、空态、错误态、权限态和刷新。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 前端] 对齐现有企业后台风格,避免营销页或花哨卡片感。
|
||||
证据:
|
||||
|
||||
## 6. 测试与验证
|
||||
|
||||
- [ ] [CONCEPT: 测试方案] 补充后端 service/API 定向测试,容器内运行,超时控制在 60s 内。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 测试方案] 补充前端视图模型、路由、组件或构建验证。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 指标与验收] 记录验证命令、结果和未覆盖风险。
|
||||
证据:
|
||||
|
||||
## 7. 文档收尾
|
||||
|
||||
- [ ] [CONCEPT: 指标与验收] 回看所有验收点,确认均有实现或验证证据。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 风险与开放问题] 更新剩余风险、后续任务和明确不做项。
|
||||
证据:
|
||||
- [ ] [CONCEPT: 功能一句话] 确认最终实现没有偏离原始目标。
|
||||
证据:
|
||||
50
.env
50
.env
@@ -1,50 +0,0 @@
|
||||
APP_NAME=X-Financial
|
||||
APP_ENV=local
|
||||
APP_DEBUG=true
|
||||
API_V1_PREFIX=/api/v1
|
||||
SETUP_COMPLETED=true
|
||||
VITE_SETUP_COMPLETED=true
|
||||
|
||||
COMPANY_NAME=YGSOFT
|
||||
COMPANY_CODE=123
|
||||
ADMIN_EMAIL='admin@admin.com'
|
||||
VITE_COMPANY_NAME=YGSOFT
|
||||
VITE_COMPANY_CODE=123
|
||||
VITE_ADMIN_EMAIL='admin@admin.com'
|
||||
# Admin login credentials are stored separately under server/.secrets/
|
||||
|
||||
WEB_HOST=10.10.10.122
|
||||
WEB_PORT=5173
|
||||
VITE_WEB_HOST=10.10.10.122
|
||||
VITE_WEB_PORT=5173
|
||||
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8000
|
||||
VITE_SERVER_HOST=0.0.0.0
|
||||
VITE_SERVER_PORT=8000
|
||||
SERVER_STARTUP_TIMEOUT=300
|
||||
SERVER_BLOCKING_STARTUP_TIMEOUT=12
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
VITE_AUTH_IDLE_TIMEOUT_MINUTES=30
|
||||
ONLYOFFICE_ENABLED=true
|
||||
ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082
|
||||
ONLYOFFICE_BACKEND_URL=http://main:8000
|
||||
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
|
||||
|
||||
POSTGRES_HOST=10.10.10.189
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=8811614287327Leo
|
||||
VITE_POSTGRES_HOST=10.10.10.189
|
||||
VITE_POSTGRES_PORT=5432
|
||||
VITE_POSTGRES_DB=postgres
|
||||
VITE_POSTGRES_USER=root
|
||||
|
||||
DATABASE_URL='postgresql+psycopg://root:8811614287327Leo@10.10.10.189:5432/postgres'
|
||||
SQLALCHEMY_ECHO=false
|
||||
|
||||
REDIS_URL=
|
||||
VITE_REDIS_URL=
|
||||
|
||||
CORS_ORIGINS='["http://10.10.10.122:5173"]'
|
||||
@@ -30,6 +30,8 @@ ONLYOFFICE_ENABLED=false
|
||||
ONLYOFFICE_PUBLIC_URL=http://127.0.0.1:8082
|
||||
ONLYOFFICE_BACKEND_URL=http://127.0.0.1:8000
|
||||
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
|
||||
HERMES_AGENT_SHARED_TOKEN=change-me-hermes
|
||||
STEWARD_AGENT_RUNTIME=langgraph
|
||||
|
||||
POSTGRES_HOST=127.0.0.1
|
||||
POSTGRES_PORT=5432
|
||||
@@ -47,4 +49,8 @@ SQLALCHEMY_ECHO=false
|
||||
REDIS_URL=
|
||||
VITE_REDIS_URL=
|
||||
|
||||
OCR_DEVICE=
|
||||
OCR_TIMEOUT_SECONDS=180
|
||||
OCR_MAX_CONCURRENT_WORKERS=1
|
||||
|
||||
CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173","http://0.0.0.0:5173"]'
|
||||
|
||||
10
.githooks/post-commit
Executable file
10
.githooks/post-commit
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Auto-append a minimal X-Financial agent work-log entry after each commit.
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
|
||||
cd "$repo_root" || exit 0
|
||||
|
||||
python3 tools/agent-change-log/update_change_log.py \
|
||||
--kind auto \
|
||||
--event "post-commit hook" \
|
||||
>/tmp/x-financial-agent-change-log-hook.log 2>&1 || true
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -7,7 +7,18 @@ web/.vite/
|
||||
.omc/
|
||||
.omx/
|
||||
.claude/
|
||||
.codex/
|
||||
*.egg-info/
|
||||
.codex/*
|
||||
!.codex/skills/
|
||||
.codex/skills/*
|
||||
!.codex/skills/agent-change-log/
|
||||
!.codex/skills/agent-change-log/**
|
||||
!.codex/skills/git-checkpoint-commit/
|
||||
!.codex/skills/git-checkpoint-commit/**
|
||||
!.codex/skills/write-development-docs/
|
||||
!.codex/skills/write-development-docs/**
|
||||
.codex-temp/
|
||||
.superpowers/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -16,3 +27,17 @@ __pycache__/
|
||||
server/.venv/
|
||||
server/.venv-ocr312
|
||||
server/.secrets/
|
||||
server/logs/
|
||||
server/storage/expense_claims/
|
||||
server/storage/finance_reports/
|
||||
server/storage/receipt_folder/
|
||||
test-results/
|
||||
.codex-remote-attachments/
|
||||
tmp-*.png
|
||||
tmp/
|
||||
.zcode/
|
||||
.nezha/
|
||||
.omo/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
1
.tmp/Yuxi
Submodule
1
.tmp/Yuxi
Submodule
Submodule .tmp/Yuxi added at fd6803e477
BIN
.tmp/lightrag_inspect/lightrag_hku-1.4.16-py3-none-any.whl
Normal file
BIN
.tmp/lightrag_inspect/lightrag_hku-1.4.16-py3-none-any.whl
Normal file
Binary file not shown.
22
.tmp/lightrag_inspect/lightrag_pkg/lightrag/__init__.py
Normal file
22
.tmp/lightrag_inspect/lightrag_pkg/lightrag/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ._version import __version__ as __version__
|
||||
|
||||
__all__ = ["LightRAG", "QueryParam", "__version__"]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in {"LightRAG", "QueryParam"}:
|
||||
from .lightrag import LightRAG, QueryParam
|
||||
|
||||
value = {"LightRAG": LightRAG, "QueryParam": QueryParam}[name]
|
||||
globals()[name] = value
|
||||
return value
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__author__ = "Zirui Guo"
|
||||
__url__ = "https://github.com/HKUDS/LightRAG"
|
||||
4
.tmp/lightrag_inspect/lightrag_pkg/lightrag/_version.py
Normal file
4
.tmp/lightrag_inspect/lightrag_pkg/lightrag/_version.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Lightweight version definitions shared by packaging and runtime code."""
|
||||
|
||||
__version__ = "1.4.16"
|
||||
__api_version__ = "0291"
|
||||
@@ -0,0 +1 @@
|
||||
from .._version import __api_version__ as __api_version__
|
||||
163
.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/auth.py
Normal file
163
.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/auth.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..utils import logger
|
||||
from .config import DEFAULT_TOKEN_SECRET, global_args
|
||||
from .passwords import verify_password
|
||||
|
||||
# use the .env that is inside the current folder
|
||||
# allows to use different .env file for each lightrag instance
|
||||
# the OS environment variables take precedence over the .env file
|
||||
load_dotenv(dotenv_path=".env", override=False)
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: str # Username
|
||||
exp: datetime # Expiration time
|
||||
role: str = "user" # User role, default is regular user
|
||||
metadata: dict = {} # Additional metadata
|
||||
|
||||
|
||||
class AuthHandler:
|
||||
def __init__(self):
|
||||
auth_accounts = global_args.auth_accounts
|
||||
self.secret = global_args.token_secret
|
||||
if not self.secret:
|
||||
if auth_accounts:
|
||||
raise ValueError(
|
||||
"TOKEN_SECRET must be explicitly set to a non-default value when AUTH_ACCOUNTS is configured."
|
||||
)
|
||||
self.secret = DEFAULT_TOKEN_SECRET
|
||||
logger.warning(
|
||||
"TOKEN_SECRET not set and AUTH_ACCOUNTS is not configured. "
|
||||
"Falling back to the default guest-mode JWT secret. "
|
||||
)
|
||||
algorithm = global_args.jwt_algorithm
|
||||
if not algorithm or algorithm.lower() == "none":
|
||||
raise ValueError(
|
||||
"JWT_ALGORITHM must be set to a secure algorithm (e.g. HS256). "
|
||||
"The 'none' algorithm is not permitted."
|
||||
)
|
||||
self.algorithm = algorithm
|
||||
self.expire_hours = global_args.token_expire_hours
|
||||
self.guest_expire_hours = global_args.guest_token_expire_hours
|
||||
self.accounts = {}
|
||||
invalid_accounts = []
|
||||
if auth_accounts:
|
||||
for account in auth_accounts.split(","):
|
||||
try:
|
||||
username, password = account.split(":", 1)
|
||||
if not username or not password:
|
||||
raise ValueError
|
||||
self.accounts[username] = password
|
||||
except ValueError:
|
||||
invalid_accounts.append(account)
|
||||
if invalid_accounts:
|
||||
invalid_entries = ", ".join(invalid_accounts)
|
||||
logger.error(f"Invalid account format in AUTH_ACCOUNTS: {invalid_entries}")
|
||||
raise ValueError(
|
||||
"AUTH_ACCOUNTS must use comma-separated user:password pairs."
|
||||
)
|
||||
|
||||
def verify_password(self, username: str, plain_password: str) -> bool:
|
||||
"""
|
||||
Verify password for a user. Supports explicit bcrypt values and plaintext.
|
||||
|
||||
Args:
|
||||
username: Username to verify
|
||||
plain_password: Plaintext password to check
|
||||
|
||||
Returns:
|
||||
bool: True if password is correct, False otherwise
|
||||
"""
|
||||
if username not in self.accounts:
|
||||
return False
|
||||
|
||||
stored_password = self.accounts[username]
|
||||
return verify_password(plain_password, stored_password)
|
||||
|
||||
def create_token(
|
||||
self,
|
||||
username: str,
|
||||
role: str = "user",
|
||||
custom_expire_hours: int = None,
|
||||
metadata: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create JWT token
|
||||
|
||||
Args:
|
||||
username: Username
|
||||
role: User role, default is "user", guest is "guest"
|
||||
custom_expire_hours: Custom expiration time (hours), if None use default value
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
str: Encoded JWT token
|
||||
"""
|
||||
# Choose default expiration time based on role
|
||||
if custom_expire_hours is None:
|
||||
if role == "guest":
|
||||
expire_hours = self.guest_expire_hours
|
||||
else:
|
||||
expire_hours = self.expire_hours
|
||||
else:
|
||||
expire_hours = custom_expire_hours
|
||||
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=expire_hours)
|
||||
|
||||
# Create payload
|
||||
payload = TokenPayload(
|
||||
sub=username, exp=expire, role=role, metadata=metadata or {}
|
||||
)
|
||||
|
||||
return jwt.encode(payload.model_dump(), self.secret, algorithm=self.algorithm)
|
||||
|
||||
def validate_token(self, token: str) -> dict:
|
||||
"""
|
||||
Validate JWT token
|
||||
|
||||
Args:
|
||||
token: JWT token
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing user information
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid or expired
|
||||
"""
|
||||
try:
|
||||
# Explicitly exclude 'none' to prevent algorithm confusion attacks
|
||||
allowed_algorithms = [self.algorithm]
|
||||
if "none" in (a.lower() for a in allowed_algorithms):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Insecure JWT algorithm configuration",
|
||||
)
|
||||
payload = jwt.decode(token, self.secret, algorithms=allowed_algorithms)
|
||||
expire_timestamp = payload["exp"]
|
||||
expire_time = datetime.fromtimestamp(expire_timestamp, timezone.utc)
|
||||
|
||||
if datetime.now(timezone.utc) > expire_time:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
|
||||
)
|
||||
|
||||
# Return complete payload instead of just username
|
||||
return {
|
||||
"username": payload["sub"],
|
||||
"role": payload.get("role", "user"),
|
||||
"metadata": payload.get("metadata", {}),
|
||||
"exp": expire_time,
|
||||
}
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
||||
)
|
||||
|
||||
|
||||
auth_handler = AuthHandler()
|
||||
697
.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/config.py
Normal file
697
.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/config.py
Normal file
@@ -0,0 +1,697 @@
|
||||
"""
|
||||
Configs for the LightRAG API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import argparse
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
from lightrag.utils import get_env_value, logger
|
||||
from lightrag.llm.binding_options import (
|
||||
GeminiEmbeddingOptions,
|
||||
GeminiLLMOptions,
|
||||
OllamaEmbeddingOptions,
|
||||
OllamaLLMOptions,
|
||||
OpenAILLMOptions,
|
||||
)
|
||||
from lightrag.base import OllamaServerInfos
|
||||
import sys
|
||||
|
||||
from lightrag.constants import (
|
||||
DEFAULT_WOKERS,
|
||||
DEFAULT_TIMEOUT,
|
||||
DEFAULT_TOP_K,
|
||||
DEFAULT_CHUNK_TOP_K,
|
||||
DEFAULT_HISTORY_TURNS,
|
||||
DEFAULT_MAX_ENTITY_TOKENS,
|
||||
DEFAULT_MAX_RELATION_TOKENS,
|
||||
DEFAULT_MAX_TOTAL_TOKENS,
|
||||
DEFAULT_COSINE_THRESHOLD,
|
||||
DEFAULT_RELATED_CHUNK_NUMBER,
|
||||
DEFAULT_MIN_RERANK_SCORE,
|
||||
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
|
||||
DEFAULT_MAX_ASYNC,
|
||||
DEFAULT_SUMMARY_MAX_TOKENS,
|
||||
DEFAULT_SUMMARY_LENGTH_RECOMMENDED,
|
||||
DEFAULT_SUMMARY_CONTEXT_SIZE,
|
||||
DEFAULT_SUMMARY_LANGUAGE,
|
||||
DEFAULT_EMBEDDING_FUNC_MAX_ASYNC,
|
||||
DEFAULT_EMBEDDING_BATCH_NUM,
|
||||
DEFAULT_OLLAMA_MODEL_NAME,
|
||||
DEFAULT_OLLAMA_MODEL_TAG,
|
||||
DEFAULT_RERANK_BINDING,
|
||||
DEFAULT_ENTITY_TYPES,
|
||||
)
|
||||
|
||||
# use the .env that is inside the current folder
|
||||
# allows to use different .env file for each lightrag instance
|
||||
# the OS environment variables take precedence over the .env file
|
||||
load_dotenv(dotenv_path=".env", override=False)
|
||||
|
||||
|
||||
ollama_server_infos = OllamaServerInfos()
|
||||
DEFAULT_TOKEN_SECRET = "lightrag-jwt-default-secret-key!"
|
||||
NO_PREFIX_SENTINEL = "NO_PREFIX"
|
||||
PROVIDER_ASYMMETRIC_EMBEDDING_BINDINGS = {"gemini", "jina", "voyageai"}
|
||||
PREFIX_ASYMMETRIC_EMBEDDING_BINDINGS = {"azure_openai", "ollama", "openai"}
|
||||
|
||||
|
||||
class DefaultRAGStorageConfig:
|
||||
KV_STORAGE = "JsonKVStorage"
|
||||
VECTOR_STORAGE = "NanoVectorDBStorage"
|
||||
GRAPH_STORAGE = "NetworkXStorage"
|
||||
DOC_STATUS_STORAGE = "JsonDocStatusStorage"
|
||||
|
||||
|
||||
def get_default_host(binding_type: str) -> str:
|
||||
default_hosts = {
|
||||
"ollama": os.getenv("LLM_BINDING_HOST", "http://localhost:11434"),
|
||||
"lollms": os.getenv("LLM_BINDING_HOST", "http://localhost:9600"),
|
||||
"azure_openai": os.getenv("AZURE_OPENAI_ENDPOINT", "https://api.openai.com/v1"),
|
||||
"openai": os.getenv("LLM_BINDING_HOST", "https://api.openai.com/v1"),
|
||||
"gemini": os.getenv(
|
||||
"LLM_BINDING_HOST", "https://generativelanguage.googleapis.com"
|
||||
),
|
||||
}
|
||||
return default_hosts.get(
|
||||
binding_type, os.getenv("LLM_BINDING_HOST", "http://localhost:11434")
|
||||
) # fallback to ollama if unknown
|
||||
|
||||
|
||||
def resolve_asymmetric_embedding_opt_in(
|
||||
*,
|
||||
binding: str,
|
||||
embedding_asymmetric: bool,
|
||||
embedding_asymmetric_configured: bool,
|
||||
query_prefix: str | None,
|
||||
document_prefix: str | None,
|
||||
query_prefix_configured: bool = False,
|
||||
document_prefix_configured: bool = False,
|
||||
) -> bool:
|
||||
"""Resolve whether query/document-aware embedding behavior should be enabled."""
|
||||
has_non_empty_prefix = bool(query_prefix or document_prefix)
|
||||
has_prefix_config = query_prefix_configured or document_prefix_configured
|
||||
|
||||
if not embedding_asymmetric:
|
||||
if has_prefix_config:
|
||||
state = "false" if embedding_asymmetric_configured else "unset"
|
||||
logger.warning(
|
||||
f"EMBEDDING_ASYMMETRIC is {state}; "
|
||||
"EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX will be ignored."
|
||||
)
|
||||
return False
|
||||
|
||||
if binding in PROVIDER_ASYMMETRIC_EMBEDDING_BINDINGS:
|
||||
if has_prefix_config:
|
||||
logger.warning(
|
||||
f"{binding} embeddings use provider task parameters for asymmetric "
|
||||
"mode; EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX will be ignored."
|
||||
)
|
||||
return True
|
||||
|
||||
if binding in PREFIX_ASYMMETRIC_EMBEDDING_BINDINGS:
|
||||
if not query_prefix_configured or not document_prefix_configured:
|
||||
raise ValueError(
|
||||
f"EMBEDDING_ASYMMETRIC=true for {binding} embeddings requires both "
|
||||
"EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX. Use "
|
||||
f"{NO_PREFIX_SENTINEL} for a side that should intentionally have no prefix."
|
||||
)
|
||||
|
||||
if not has_non_empty_prefix:
|
||||
raise ValueError(
|
||||
"At least one of EMBEDDING_QUERY_PREFIX or EMBEDDING_DOCUMENT_PREFIX "
|
||||
f"must be non-empty. Use {NO_PREFIX_SENTINEL} only for the side that "
|
||||
"should intentionally have no prefix."
|
||||
)
|
||||
return True
|
||||
|
||||
raise ValueError(
|
||||
f"EMBEDDING_ASYMMETRIC=true is not supported for {binding} embeddings."
|
||||
)
|
||||
|
||||
|
||||
def get_embedding_prefix_config(env_key: str) -> tuple[str | None, bool]:
|
||||
"""Read an embedding prefix and whether it was explicitly configured."""
|
||||
if env_key not in os.environ:
|
||||
return None, False
|
||||
|
||||
value = os.environ[env_key]
|
||||
if value == "None":
|
||||
return None, False
|
||||
if value == NO_PREFIX_SENTINEL:
|
||||
return "", True
|
||||
if value == "":
|
||||
raise ValueError(
|
||||
f"{env_key} is empty. Use {NO_PREFIX_SENTINEL} to explicitly request "
|
||||
"no prefix, or remove the variable to leave it unconfigured."
|
||||
)
|
||||
return value, True
|
||||
|
||||
|
||||
def validate_auth_configuration(args: argparse.Namespace) -> None:
|
||||
"""Reject insecure JWT auth settings before the API starts."""
|
||||
auth_accounts = (getattr(args, "auth_accounts", "") or "").strip()
|
||||
token_secret = (getattr(args, "token_secret", "") or "").strip()
|
||||
|
||||
if auth_accounts and (not token_secret or token_secret == DEFAULT_TOKEN_SECRET):
|
||||
raise ValueError(
|
||||
"TOKEN_SECRET must be explicitly set to a non-default value when AUTH_ACCOUNTS is configured."
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""
|
||||
Parse command line arguments with environment variable fallback
|
||||
|
||||
Args:
|
||||
is_uvicorn_mode: Whether running under uvicorn mode
|
||||
|
||||
Returns:
|
||||
argparse.Namespace: Parsed arguments
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="LightRAG API Server")
|
||||
|
||||
# Server configuration
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default=get_env_value("HOST", "0.0.0.0"),
|
||||
help="Server host (default: from env or 0.0.0.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=get_env_value("PORT", 9621, int),
|
||||
help="Server port (default: from env or 9621)",
|
||||
)
|
||||
|
||||
# Directory configuration
|
||||
parser.add_argument(
|
||||
"--working-dir",
|
||||
default=get_env_value("WORKING_DIR", "./rag_storage"),
|
||||
help="Working directory for RAG storage (default: from env or ./rag_storage)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-dir",
|
||||
default=get_env_value("INPUT_DIR", "./inputs"),
|
||||
help="Directory containing input documents (default: from env or ./inputs)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
default=get_env_value("TIMEOUT", DEFAULT_TIMEOUT, int, special_none=True),
|
||||
type=int,
|
||||
help="Timeout in seconds (useful when using slow AI). Use None for infinite timeout",
|
||||
)
|
||||
|
||||
# RAG configuration
|
||||
parser.add_argument(
|
||||
"--max-async",
|
||||
type=int,
|
||||
default=get_env_value("MAX_ASYNC", DEFAULT_MAX_ASYNC, int),
|
||||
help=f"Maximum async operations (default: from env or {DEFAULT_MAX_ASYNC})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--summary-max-tokens",
|
||||
type=int,
|
||||
default=get_env_value("SUMMARY_MAX_TOKENS", DEFAULT_SUMMARY_MAX_TOKENS, int),
|
||||
help=f"Maximum token size for entity/relation summary(default: from env or {DEFAULT_SUMMARY_MAX_TOKENS})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--summary-context-size",
|
||||
type=int,
|
||||
default=get_env_value(
|
||||
"SUMMARY_CONTEXT_SIZE", DEFAULT_SUMMARY_CONTEXT_SIZE, int
|
||||
),
|
||||
help=f"LLM Summary Context size (default: from env or {DEFAULT_SUMMARY_CONTEXT_SIZE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--summary-length-recommended",
|
||||
type=int,
|
||||
default=get_env_value(
|
||||
"SUMMARY_LENGTH_RECOMMENDED", DEFAULT_SUMMARY_LENGTH_RECOMMENDED, int
|
||||
),
|
||||
help=f"LLM Summary Context size (default: from env or {DEFAULT_SUMMARY_LENGTH_RECOMMENDED})",
|
||||
)
|
||||
|
||||
# Logging configuration
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
default=get_env_value("LOG_LEVEL", "INFO"),
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
help="Logging level (default: from env or INFO)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
default=get_env_value("VERBOSE", False, bool),
|
||||
help="Enable verbose debug output(only valid for DEBUG log-level)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
type=str,
|
||||
default=get_env_value("LIGHTRAG_API_KEY", None),
|
||||
help="API key for authentication. This protects lightrag server against unauthorized access",
|
||||
)
|
||||
|
||||
# Optional https parameters
|
||||
parser.add_argument(
|
||||
"--ssl",
|
||||
action="store_true",
|
||||
default=get_env_value("SSL", False, bool),
|
||||
help="Enable HTTPS (default: from env or False)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ssl-certfile",
|
||||
default=get_env_value("SSL_CERTFILE", None),
|
||||
help="Path to SSL certificate file (required if --ssl is enabled)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ssl-keyfile",
|
||||
default=get_env_value("SSL_KEYFILE", None),
|
||||
help="Path to SSL private key file (required if --ssl is enabled)",
|
||||
)
|
||||
|
||||
# Ollama model configuration
|
||||
parser.add_argument(
|
||||
"--simulated-model-name",
|
||||
type=str,
|
||||
default=get_env_value("OLLAMA_EMULATING_MODEL_NAME", DEFAULT_OLLAMA_MODEL_NAME),
|
||||
help="Name for the simulated Ollama model (default: from env or lightrag)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--simulated-model-tag",
|
||||
type=str,
|
||||
default=get_env_value("OLLAMA_EMULATING_MODEL_TAG", DEFAULT_OLLAMA_MODEL_TAG),
|
||||
help="Tag for the simulated Ollama model (default: from env or latest)",
|
||||
)
|
||||
|
||||
# Namespace
|
||||
parser.add_argument(
|
||||
"--workspace",
|
||||
type=str,
|
||||
default=get_env_value("WORKSPACE", ""),
|
||||
help="Default workspace for all storage",
|
||||
)
|
||||
|
||||
# Server workers configuration
|
||||
parser.add_argument(
|
||||
"--workers",
|
||||
type=int,
|
||||
default=get_env_value("WORKERS", DEFAULT_WOKERS, int),
|
||||
help="Number of worker processes (default: from env or 1)",
|
||||
)
|
||||
|
||||
# LLM and embedding bindings
|
||||
parser.add_argument(
|
||||
"--llm-binding",
|
||||
type=str,
|
||||
default=get_env_value("LLM_BINDING", "ollama"),
|
||||
choices=[
|
||||
"lollms",
|
||||
"ollama",
|
||||
"openai",
|
||||
"openai-ollama",
|
||||
"azure_openai",
|
||||
"aws_bedrock",
|
||||
"gemini",
|
||||
],
|
||||
help="LLM binding type (default: from env or ollama)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--embedding-binding",
|
||||
type=str,
|
||||
default=get_env_value("EMBEDDING_BINDING", "ollama"),
|
||||
choices=[
|
||||
"lollms",
|
||||
"ollama",
|
||||
"openai",
|
||||
"azure_openai",
|
||||
"aws_bedrock",
|
||||
"jina",
|
||||
"gemini",
|
||||
"voyageai",
|
||||
],
|
||||
help="Embedding binding type (default: from env or ollama)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rerank-binding",
|
||||
type=str,
|
||||
default=get_env_value("RERANK_BINDING", DEFAULT_RERANK_BINDING),
|
||||
choices=["null", "cohere", "jina", "aliyun"],
|
||||
help=f"Rerank binding type (default: from env or {DEFAULT_RERANK_BINDING})",
|
||||
)
|
||||
|
||||
# Document loading engine configuration
|
||||
parser.add_argument(
|
||||
"--docling",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable DOCLING document loading engine (default: from env or DEFAULT)",
|
||||
)
|
||||
|
||||
# Conditionally add binding-specific options (Ollama, OpenAI, Azure OpenAI, Gemini)
|
||||
# This registers command line arguments (e.g., --openai-llm-temperature)
|
||||
# and reads corresponding environment variables (e.g., OPENAI_LLM_TEMPERATURE)
|
||||
|
||||
# Determine LLM binding value consistently from command line or environment
|
||||
llm_binding_value = None
|
||||
if "--llm-binding" in sys.argv:
|
||||
try:
|
||||
idx = sys.argv.index("--llm-binding")
|
||||
if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("-"):
|
||||
llm_binding_value = sys.argv[idx + 1]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
# Fall back to environment variable using same function as argparse default
|
||||
if llm_binding_value is None:
|
||||
llm_binding_value = get_env_value("LLM_BINDING", "ollama")
|
||||
|
||||
# Add LLM binding options based on determined value
|
||||
if llm_binding_value == "ollama":
|
||||
OllamaLLMOptions.add_args(parser)
|
||||
elif llm_binding_value in ["openai", "azure_openai"]:
|
||||
OpenAILLMOptions.add_args(parser)
|
||||
elif llm_binding_value == "gemini":
|
||||
GeminiLLMOptions.add_args(parser)
|
||||
|
||||
# Determine embedding binding value consistently from command line or environment
|
||||
embedding_binding_value = None
|
||||
if "--embedding-binding" in sys.argv:
|
||||
try:
|
||||
idx = sys.argv.index("--embedding-binding")
|
||||
if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("-"):
|
||||
embedding_binding_value = sys.argv[idx + 1]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
# Fall back to environment variable using same function as argparse default
|
||||
if embedding_binding_value is None:
|
||||
embedding_binding_value = get_env_value("EMBEDDING_BINDING", "ollama")
|
||||
|
||||
# Add embedding binding options based on determined value
|
||||
if embedding_binding_value == "ollama":
|
||||
OllamaEmbeddingOptions.add_args(parser)
|
||||
elif embedding_binding_value == "gemini":
|
||||
GeminiEmbeddingOptions.add_args(parser)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# convert relative path to absolute path
|
||||
args.working_dir = os.path.abspath(args.working_dir)
|
||||
args.input_dir = os.path.abspath(args.input_dir)
|
||||
|
||||
# Inject storage configuration from environment variables
|
||||
args.kv_storage = get_env_value(
|
||||
"LIGHTRAG_KV_STORAGE", DefaultRAGStorageConfig.KV_STORAGE
|
||||
)
|
||||
args.doc_status_storage = get_env_value(
|
||||
"LIGHTRAG_DOC_STATUS_STORAGE", DefaultRAGStorageConfig.DOC_STATUS_STORAGE
|
||||
)
|
||||
args.graph_storage = get_env_value(
|
||||
"LIGHTRAG_GRAPH_STORAGE", DefaultRAGStorageConfig.GRAPH_STORAGE
|
||||
)
|
||||
args.vector_storage = get_env_value(
|
||||
"LIGHTRAG_VECTOR_STORAGE", DefaultRAGStorageConfig.VECTOR_STORAGE
|
||||
)
|
||||
|
||||
# Get MAX_PARALLEL_INSERT from environment
|
||||
args.max_parallel_insert = get_env_value("MAX_PARALLEL_INSERT", 2, int)
|
||||
|
||||
# Get MAX_GRAPH_NODES from environment
|
||||
args.max_graph_nodes = get_env_value("MAX_GRAPH_NODES", 1000, int)
|
||||
|
||||
# Handle openai-ollama special case
|
||||
if args.llm_binding == "openai-ollama":
|
||||
args.llm_binding = "openai"
|
||||
args.embedding_binding = "ollama"
|
||||
|
||||
args.llm_binding_host = get_env_value(
|
||||
"LLM_BINDING_HOST", get_default_host(args.llm_binding)
|
||||
)
|
||||
args.embedding_binding_host = get_env_value(
|
||||
"EMBEDDING_BINDING_HOST", get_default_host(args.embedding_binding)
|
||||
)
|
||||
args.llm_binding_api_key = get_env_value("LLM_BINDING_API_KEY", None)
|
||||
args.embedding_binding_api_key = get_env_value("EMBEDDING_BINDING_API_KEY", "")
|
||||
|
||||
# Inject model configuration
|
||||
args.llm_model = get_env_value("LLM_MODEL", "mistral-nemo:latest")
|
||||
# EMBEDDING_MODEL defaults to None - each binding will use its own default model
|
||||
# e.g., OpenAI uses "text-embedding-3-small", Jina uses "jina-embeddings-v4"
|
||||
args.embedding_model = get_env_value("EMBEDDING_MODEL", None, special_none=True)
|
||||
# EMBEDDING_DIM defaults to None - each binding will use its own default dimension
|
||||
# Value is inherited from provider defaults via wrap_embedding_func_with_attrs decorator
|
||||
args.embedding_dim = get_env_value("EMBEDDING_DIM", None, int, special_none=True)
|
||||
args.embedding_send_dim = get_env_value("EMBEDDING_SEND_DIM", False, bool)
|
||||
|
||||
# Inject chunk configuration
|
||||
args.chunk_size = get_env_value("CHUNK_SIZE", 1200, int)
|
||||
args.chunk_overlap_size = get_env_value("CHUNK_OVERLAP_SIZE", 100, int)
|
||||
|
||||
# Inject LLM cache configuration
|
||||
args.enable_llm_cache_for_extract = get_env_value(
|
||||
"ENABLE_LLM_CACHE_FOR_EXTRACT", True, bool
|
||||
)
|
||||
args.enable_llm_cache = get_env_value("ENABLE_LLM_CACHE", True, bool)
|
||||
|
||||
# Set document_loading_engine from --docling flag
|
||||
if args.docling:
|
||||
args.document_loading_engine = "DOCLING"
|
||||
else:
|
||||
args.document_loading_engine = get_env_value(
|
||||
"DOCUMENT_LOADING_ENGINE", "DEFAULT"
|
||||
)
|
||||
|
||||
# PDF decryption password
|
||||
args.pdf_decrypt_password = get_env_value("PDF_DECRYPT_PASSWORD", None)
|
||||
|
||||
# Add environment variables that were previously read directly
|
||||
args.cors_origins = get_env_value("CORS_ORIGINS", "*")
|
||||
args.summary_language = get_env_value("SUMMARY_LANGUAGE", DEFAULT_SUMMARY_LANGUAGE)
|
||||
args.entity_types = get_env_value("ENTITY_TYPES", DEFAULT_ENTITY_TYPES, list)
|
||||
args.whitelist_paths = get_env_value("WHITELIST_PATHS", "/health,/api/*")
|
||||
|
||||
# For JWT Auth
|
||||
args.auth_accounts = get_env_value("AUTH_ACCOUNTS", "")
|
||||
args.token_secret = get_env_value("TOKEN_SECRET", None)
|
||||
args.token_expire_hours = get_env_value("TOKEN_EXPIRE_HOURS", 48, float)
|
||||
args.guest_token_expire_hours = get_env_value("GUEST_TOKEN_EXPIRE_HOURS", 24, float)
|
||||
args.jwt_algorithm = get_env_value("JWT_ALGORITHM", "HS256")
|
||||
|
||||
# Token auto-renewal configuration (sliding window expiration)
|
||||
args.token_auto_renew = get_env_value("TOKEN_AUTO_RENEW", True, bool)
|
||||
args.token_renew_threshold = get_env_value("TOKEN_RENEW_THRESHOLD", 0.5, float)
|
||||
|
||||
# Rerank model configuration
|
||||
args.rerank_model = get_env_value("RERANK_MODEL", None)
|
||||
args.rerank_binding_host = get_env_value("RERANK_BINDING_HOST", None)
|
||||
args.rerank_binding_api_key = get_env_value("RERANK_BINDING_API_KEY", None)
|
||||
# Note: rerank_binding is already set by argparse, no need to override from env
|
||||
|
||||
# Min rerank score configuration
|
||||
args.min_rerank_score = get_env_value(
|
||||
"MIN_RERANK_SCORE", DEFAULT_MIN_RERANK_SCORE, float
|
||||
)
|
||||
|
||||
# Query configuration
|
||||
args.history_turns = get_env_value("HISTORY_TURNS", DEFAULT_HISTORY_TURNS, int)
|
||||
args.top_k = get_env_value("TOP_K", DEFAULT_TOP_K, int)
|
||||
args.chunk_top_k = get_env_value("CHUNK_TOP_K", DEFAULT_CHUNK_TOP_K, int)
|
||||
args.max_entity_tokens = get_env_value(
|
||||
"MAX_ENTITY_TOKENS", DEFAULT_MAX_ENTITY_TOKENS, int
|
||||
)
|
||||
args.max_relation_tokens = get_env_value(
|
||||
"MAX_RELATION_TOKENS", DEFAULT_MAX_RELATION_TOKENS, int
|
||||
)
|
||||
args.max_total_tokens = get_env_value(
|
||||
"MAX_TOTAL_TOKENS", DEFAULT_MAX_TOTAL_TOKENS, int
|
||||
)
|
||||
args.cosine_threshold = get_env_value(
|
||||
"COSINE_THRESHOLD", DEFAULT_COSINE_THRESHOLD, float
|
||||
)
|
||||
args.related_chunk_number = get_env_value(
|
||||
"RELATED_CHUNK_NUMBER", DEFAULT_RELATED_CHUNK_NUMBER, int
|
||||
)
|
||||
|
||||
# Add missing environment variables for health endpoint
|
||||
args.force_llm_summary_on_merge = get_env_value(
|
||||
"FORCE_LLM_SUMMARY_ON_MERGE", DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, int
|
||||
)
|
||||
args.embedding_func_max_async = get_env_value(
|
||||
"EMBEDDING_FUNC_MAX_ASYNC", DEFAULT_EMBEDDING_FUNC_MAX_ASYNC, int
|
||||
)
|
||||
args.embedding_batch_num = get_env_value(
|
||||
"EMBEDDING_BATCH_NUM", DEFAULT_EMBEDDING_BATCH_NUM, int
|
||||
)
|
||||
|
||||
# Embedding token limit configuration
|
||||
args.embedding_token_limit = get_env_value(
|
||||
"EMBEDDING_TOKEN_LIMIT", None, int, special_none=True
|
||||
)
|
||||
|
||||
# File upload size limit (in bytes, None for unlimited)
|
||||
# Default: 100MB (104857600 bytes)
|
||||
args.max_upload_size = get_env_value(
|
||||
"MAX_UPLOAD_SIZE", 104857600, int, special_none=True
|
||||
)
|
||||
|
||||
# Embedding prefix configuration for context-aware embeddings. Empty prefixes
|
||||
# must be explicit via NO_PREFIX so missing config is distinguishable.
|
||||
(
|
||||
args.embedding_document_prefix,
|
||||
args.embedding_document_prefix_configured,
|
||||
) = get_embedding_prefix_config("EMBEDDING_DOCUMENT_PREFIX")
|
||||
(
|
||||
args.embedding_query_prefix,
|
||||
args.embedding_query_prefix_configured,
|
||||
) = get_embedding_prefix_config("EMBEDDING_QUERY_PREFIX")
|
||||
args.embedding_prefix_no_prefix_sentinel = NO_PREFIX_SENTINEL
|
||||
args.embedding_prefixes_configured = (
|
||||
args.embedding_document_prefix_configured
|
||||
or args.embedding_query_prefix_configured
|
||||
)
|
||||
# Asymmetric embedding behavior toggle
|
||||
args.embedding_asymmetric_configured = "EMBEDDING_ASYMMETRIC" in os.environ
|
||||
args.embedding_asymmetric = get_env_value("EMBEDDING_ASYMMETRIC", False, bool)
|
||||
|
||||
ollama_server_infos.LIGHTRAG_NAME = args.simulated_model_name
|
||||
ollama_server_infos.LIGHTRAG_TAG = args.simulated_model_tag
|
||||
|
||||
# Sanitize workspace: only alphanumeric characters and underscores are allowed
|
||||
if args.workspace:
|
||||
sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", args.workspace)
|
||||
if sanitized != args.workspace:
|
||||
logging.warning(
|
||||
f"Workspace name '{args.workspace}' contains invalid characters. "
|
||||
f"It has been sanitized to '{sanitized}'. "
|
||||
"Only alphanumeric characters and underscores are allowed."
|
||||
)
|
||||
args.workspace = sanitized
|
||||
|
||||
validate_auth_configuration(args)
|
||||
return args
|
||||
|
||||
|
||||
def update_uvicorn_mode_config():
|
||||
# If in uvicorn mode and workers > 1, force it to 1 and log warning
|
||||
if global_args.workers > 1:
|
||||
original_workers = global_args.workers
|
||||
global_args.workers = 1
|
||||
# Log warning directly here
|
||||
logging.debug(
|
||||
f">> Forcing workers=1 in uvicorn mode(Ignoring workers={original_workers})"
|
||||
)
|
||||
|
||||
|
||||
# Global configuration with lazy initialization
|
||||
_global_args = None
|
||||
_initialized = False
|
||||
|
||||
|
||||
def initialize_config(args=None, force=False):
|
||||
"""Initialize global configuration
|
||||
|
||||
This function allows explicit initialization of the configuration,
|
||||
which is useful for programmatic usage, testing, or embedding LightRAG
|
||||
in other applications.
|
||||
|
||||
Args:
|
||||
args: Pre-parsed argparse.Namespace or None to parse from sys.argv
|
||||
force: Force re-initialization even if already initialized
|
||||
|
||||
Returns:
|
||||
argparse.Namespace: The configured arguments
|
||||
|
||||
Example:
|
||||
# Use parsed command line arguments (default)
|
||||
initialize_config()
|
||||
|
||||
# Use custom configuration programmatically
|
||||
custom_args = argparse.Namespace(
|
||||
host='localhost',
|
||||
port=8080,
|
||||
working_dir='./custom_rag',
|
||||
# ... other config
|
||||
)
|
||||
initialize_config(custom_args)
|
||||
"""
|
||||
global _global_args, _initialized
|
||||
|
||||
if _initialized and not force:
|
||||
return _global_args
|
||||
|
||||
resolved_args = args if args is not None else parse_args()
|
||||
validate_auth_configuration(resolved_args)
|
||||
_global_args = resolved_args
|
||||
_initialized = True
|
||||
return _global_args
|
||||
|
||||
|
||||
def get_config():
|
||||
"""Get global configuration, auto-initializing if needed
|
||||
|
||||
Returns:
|
||||
argparse.Namespace: The configured arguments
|
||||
"""
|
||||
if not _initialized:
|
||||
initialize_config()
|
||||
return _global_args
|
||||
|
||||
|
||||
class _GlobalArgsProxy:
|
||||
"""Proxy object that auto-initializes configuration on first access
|
||||
|
||||
This maintains backward compatibility with existing code while
|
||||
allowing programmatic control over initialization timing.
|
||||
|
||||
The proxy fully delegates to the underlying argparse.Namespace,
|
||||
including support for vars() calls which is used by binding_options
|
||||
to extract provider-specific configuration options.
|
||||
"""
|
||||
|
||||
def __getattribute__(self, name):
|
||||
"""Override attribute access to support vars() and regular attribute access.
|
||||
|
||||
This method intercepts __dict__ access (used by vars()) and delegates
|
||||
to the underlying _global_args namespace, ensuring binding options
|
||||
can be properly extracted.
|
||||
"""
|
||||
global _initialized, _global_args
|
||||
|
||||
# Handle __dict__ access for vars() support
|
||||
if name == "__dict__":
|
||||
if not _initialized:
|
||||
initialize_config()
|
||||
return vars(_global_args)
|
||||
|
||||
# Handle class-level attributes that should come from the proxy itself
|
||||
if name in ("__class__", "__repr__", "__getattribute__", "__setattr__"):
|
||||
return object.__getattribute__(self, name)
|
||||
|
||||
# Delegate all other attribute access to the underlying namespace
|
||||
if not _initialized:
|
||||
initialize_config()
|
||||
return getattr(_global_args, name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
global _initialized, _global_args
|
||||
if not _initialized:
|
||||
initialize_config()
|
||||
setattr(_global_args, name, value)
|
||||
|
||||
def __repr__(self):
|
||||
global _initialized, _global_args
|
||||
if not _initialized:
|
||||
return "<GlobalArgsProxy: Not initialized>"
|
||||
return repr(_global_args)
|
||||
|
||||
|
||||
# Create proxy instance for backward compatibility
|
||||
# Existing code like `from config import global_args` continues to work
|
||||
# The proxy will auto-initialize on first attribute access
|
||||
global_args = _GlobalArgsProxy()
|
||||
@@ -0,0 +1,162 @@
|
||||
# gunicorn_config.py
|
||||
import os
|
||||
import logging
|
||||
from lightrag.kg.shared_storage import finalize_share_data
|
||||
from lightrag.utils import setup_logger, get_env_value
|
||||
from lightrag.constants import (
|
||||
DEFAULT_LOG_MAX_BYTES,
|
||||
DEFAULT_LOG_BACKUP_COUNT,
|
||||
DEFAULT_LOG_FILENAME,
|
||||
)
|
||||
|
||||
|
||||
# Get log directory path from environment variable
|
||||
log_dir = os.getenv("LOG_DIR", os.getcwd())
|
||||
log_file_path = os.path.abspath(os.path.join(log_dir, DEFAULT_LOG_FILENAME))
|
||||
|
||||
# Ensure log directory exists
|
||||
os.makedirs(os.path.dirname(log_file_path), exist_ok=True)
|
||||
|
||||
# Get log file max size and backup count from environment variables
|
||||
log_max_bytes = get_env_value("LOG_MAX_BYTES", DEFAULT_LOG_MAX_BYTES, int)
|
||||
log_backup_count = get_env_value("LOG_BACKUP_COUNT", DEFAULT_LOG_BACKUP_COUNT, int)
|
||||
|
||||
# These variables will be set by run_with_gunicorn.py
|
||||
workers = None
|
||||
bind = None
|
||||
loglevel = None
|
||||
certfile = None
|
||||
keyfile = None
|
||||
|
||||
# Enable preload_app option
|
||||
preload_app = True
|
||||
|
||||
# Use Uvicorn worker
|
||||
worker_class = "uvicorn.workers.UvicornWorker"
|
||||
|
||||
# Other Gunicorn configurations
|
||||
|
||||
# Logging configuration
|
||||
errorlog = os.getenv("ERROR_LOG", log_file_path) # Default write to lightrag.log
|
||||
accesslog = os.getenv("ACCESS_LOG", log_file_path) # Default write to lightrag.log
|
||||
|
||||
logconfig_dict = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "standard",
|
||||
"stream": "ext://sys.stdout",
|
||||
},
|
||||
"file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"formatter": "standard",
|
||||
"filename": log_file_path,
|
||||
"maxBytes": log_max_bytes,
|
||||
"backupCount": log_backup_count,
|
||||
"encoding": "utf8",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"path_filter": {
|
||||
"()": "lightrag.utils.LightragPathFilter",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"lightrag": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": loglevel.upper() if loglevel else "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"gunicorn": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": loglevel.upper() if loglevel else "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"gunicorn.error": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": loglevel.upper() if loglevel else "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"gunicorn.access": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": loglevel.upper() if loglevel else "INFO",
|
||||
"propagate": False,
|
||||
"filters": ["path_filter"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def on_starting(server):
|
||||
"""
|
||||
Executed when Gunicorn starts, before forking the first worker processes
|
||||
You can use this function to do more initialization tasks for all processes
|
||||
"""
|
||||
print("=" * 80)
|
||||
print(f"GUNICORN MASTER PROCESS: on_starting jobs for {workers} worker(s)")
|
||||
print(f"Process ID: {os.getpid()}")
|
||||
print("=" * 80)
|
||||
|
||||
# Memory usage monitoring
|
||||
try:
|
||||
import psutil
|
||||
|
||||
process = psutil.Process(os.getpid())
|
||||
memory_info = process.memory_info()
|
||||
msg = (
|
||||
f"Memory usage after initialization: {memory_info.rss / 1024 / 1024:.2f} MB"
|
||||
)
|
||||
print(msg)
|
||||
except ImportError:
|
||||
print("psutil not installed, skipping memory usage reporting")
|
||||
|
||||
# Log the location of the LightRAG log file
|
||||
print(f"LightRAG log file: {log_file_path}\n")
|
||||
|
||||
print("Gunicorn initialization complete, forking workers...\n")
|
||||
|
||||
|
||||
def on_exit(server):
|
||||
"""
|
||||
Executed when Gunicorn is shutting down.
|
||||
This is a good place to release shared resources.
|
||||
"""
|
||||
print("=" * 80)
|
||||
print("GUNICORN MASTER PROCESS: Shutting down")
|
||||
print(f"Process ID: {os.getpid()}")
|
||||
|
||||
print("Finalizing shared storage...")
|
||||
finalize_share_data()
|
||||
|
||||
print("Gunicorn shutdown complete")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
def post_fork(server, worker):
|
||||
"""
|
||||
Executed after a worker has been forked.
|
||||
This is a good place to set up worker-specific configurations.
|
||||
"""
|
||||
# Set up main loggers
|
||||
log_level = loglevel.upper() if loglevel else "INFO"
|
||||
setup_logger("uvicorn", log_level, add_filter=False, log_file_path=log_file_path)
|
||||
setup_logger(
|
||||
"uvicorn.access", log_level, add_filter=True, log_file_path=log_file_path
|
||||
)
|
||||
setup_logger("lightrag", log_level, add_filter=True, log_file_path=log_file_path)
|
||||
|
||||
# Set up lightrag submodule loggers
|
||||
for name in logging.root.manager.loggerDict:
|
||||
if name.startswith("lightrag."):
|
||||
setup_logger(name, log_level, add_filter=True, log_file_path=log_file_path)
|
||||
|
||||
# Disable uvicorn.error logger
|
||||
uvicorn_error_logger = logging.getLogger("uvicorn.error")
|
||||
uvicorn_error_logger.handlers = []
|
||||
uvicorn_error_logger.setLevel(logging.CRITICAL)
|
||||
uvicorn_error_logger.propagate = False
|
||||
1628
.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/lightrag_server.py
Normal file
1628
.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/lightrag_server.py
Normal file
File diff suppressed because it is too large
Load Diff
26
.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/passwords.py
Normal file
26
.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/passwords.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import bcrypt
|
||||
|
||||
BCRYPT_PASSWORD_PREFIX = "{bcrypt}"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Return an AUTH_ACCOUNTS-ready bcrypt password value."""
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
|
||||
return f"{BCRYPT_PASSWORD_PREFIX}{hashed}"
|
||||
|
||||
|
||||
def verify_password(plain_password: str, stored_password: str) -> bool:
|
||||
"""Verify a plaintext password against a stored password spec."""
|
||||
if stored_password.startswith(BCRYPT_PASSWORD_PREFIX):
|
||||
hashed_password = stored_password[len(BCRYPT_PASSWORD_PREFIX) :]
|
||||
if not hashed_password:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return stored_password == plain_password
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
This module contains all the routers for the LightRAG API.
|
||||
"""
|
||||
|
||||
from .document_routes import router as document_router
|
||||
from .query_routes import router as query_router
|
||||
from .graph_routes import router as graph_router
|
||||
from .ollama_api import OllamaAPI
|
||||
|
||||
__all__ = ["document_router", "query_router", "graph_router", "OllamaAPI"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
"""
|
||||
This module contains all graph-related routes for the LightRAG API.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
import traceback
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lightrag.utils import logger
|
||||
from ..utils_api import get_combined_auth_dependency
|
||||
|
||||
router = APIRouter(tags=["graph"])
|
||||
|
||||
|
||||
class EntityUpdateRequest(BaseModel):
|
||||
entity_name: str
|
||||
updated_data: Dict[str, Any]
|
||||
allow_rename: bool = False
|
||||
allow_merge: bool = False
|
||||
|
||||
|
||||
class RelationUpdateRequest(BaseModel):
|
||||
source_id: str
|
||||
target_id: str
|
||||
updated_data: Dict[str, Any]
|
||||
|
||||
|
||||
class EntityMergeRequest(BaseModel):
|
||||
entities_to_change: list[str] = Field(
|
||||
...,
|
||||
description="List of entity names to be merged and deleted. These are typically duplicate or misspelled entities.",
|
||||
min_length=1,
|
||||
examples=[["Elon Msk", "Ellon Musk"]],
|
||||
)
|
||||
entity_to_change_into: str = Field(
|
||||
...,
|
||||
description="Target entity name that will receive all relationships from the source entities. This entity will be preserved.",
|
||||
min_length=1,
|
||||
examples=["Elon Musk"],
|
||||
)
|
||||
|
||||
|
||||
class EntityCreateRequest(BaseModel):
|
||||
entity_name: str = Field(
|
||||
...,
|
||||
description="Unique name for the new entity",
|
||||
min_length=1,
|
||||
examples=["Tesla"],
|
||||
)
|
||||
entity_data: Dict[str, Any] = Field(
|
||||
...,
|
||||
description="Dictionary containing entity properties. Common fields include 'description' and 'entity_type'.",
|
||||
examples=[
|
||||
{
|
||||
"description": "Electric vehicle manufacturer",
|
||||
"entity_type": "ORGANIZATION",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class RelationCreateRequest(BaseModel):
|
||||
source_entity: str = Field(
|
||||
...,
|
||||
description="Name of the source entity. This entity must already exist in the knowledge graph.",
|
||||
min_length=1,
|
||||
examples=["Elon Musk"],
|
||||
)
|
||||
target_entity: str = Field(
|
||||
...,
|
||||
description="Name of the target entity. This entity must already exist in the knowledge graph.",
|
||||
min_length=1,
|
||||
examples=["Tesla"],
|
||||
)
|
||||
relation_data: Dict[str, Any] = Field(
|
||||
...,
|
||||
description="Dictionary containing relationship properties. Common fields include 'description', 'keywords', and 'weight'.",
|
||||
examples=[
|
||||
{
|
||||
"description": "Elon Musk is the CEO of Tesla",
|
||||
"keywords": "CEO, founder",
|
||||
"weight": 1.0,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def create_graph_routes(rag, api_key: Optional[str] = None):
|
||||
combined_auth = get_combined_auth_dependency(api_key)
|
||||
|
||||
@router.get("/graph/label/list", dependencies=[Depends(combined_auth)])
|
||||
async def get_graph_labels():
|
||||
"""
|
||||
Get all graph labels
|
||||
|
||||
Returns:
|
||||
List[str]: List of graph labels
|
||||
"""
|
||||
try:
|
||||
return await rag.get_graph_labels()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting graph labels: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error getting graph labels: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/graph/label/popular", dependencies=[Depends(combined_auth)])
|
||||
async def get_popular_labels(
|
||||
limit: int = Query(
|
||||
300, description="Maximum number of popular labels to return", ge=1, le=1000
|
||||
),
|
||||
):
|
||||
"""
|
||||
Get popular labels by node degree (most connected entities)
|
||||
|
||||
Args:
|
||||
limit (int): Maximum number of labels to return (default: 300, max: 1000)
|
||||
|
||||
Returns:
|
||||
List[str]: List of popular labels sorted by degree (highest first)
|
||||
"""
|
||||
try:
|
||||
return await rag.chunk_entity_relation_graph.get_popular_labels(limit)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting popular labels: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error getting popular labels: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/graph/label/search", dependencies=[Depends(combined_auth)])
|
||||
async def search_labels(
|
||||
q: str = Query(..., description="Search query string"),
|
||||
limit: int = Query(
|
||||
50, description="Maximum number of search results to return", ge=1, le=100
|
||||
),
|
||||
):
|
||||
"""
|
||||
Search labels with fuzzy matching
|
||||
|
||||
Args:
|
||||
q (str): Search query string
|
||||
limit (int): Maximum number of results to return (default: 50, max: 100)
|
||||
|
||||
Returns:
|
||||
List[str]: List of matching labels sorted by relevance
|
||||
"""
|
||||
try:
|
||||
return await rag.chunk_entity_relation_graph.search_labels(q, limit)
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching labels with query '{q}': {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error searching labels: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/graphs", dependencies=[Depends(combined_auth)])
|
||||
async def get_knowledge_graph(
|
||||
label: str = Query(..., description="Label to get knowledge graph for"),
|
||||
max_depth: int = Query(3, description="Maximum depth of graph", ge=1),
|
||||
max_nodes: int = Query(1000, description="Maximum nodes to return", ge=1),
|
||||
):
|
||||
"""
|
||||
Retrieve a connected subgraph of nodes where the label includes the specified label.
|
||||
When reducing the number of nodes, the prioritization criteria are as follows:
|
||||
1. Hops(path) to the staring node take precedence
|
||||
2. Followed by the degree of the nodes
|
||||
|
||||
Args:
|
||||
label (str): Label of the starting node
|
||||
max_depth (int, optional): Maximum depth of the subgraph,Defaults to 3
|
||||
max_nodes: Maxiumu nodes to return
|
||||
|
||||
Returns:
|
||||
Dict[str, List[str]]: Knowledge graph for label
|
||||
"""
|
||||
try:
|
||||
# Log the label parameter to check for leading spaces
|
||||
logger.debug(
|
||||
f"get_knowledge_graph called with label: '{label}' (length: {len(label)}, repr: {repr(label)})"
|
||||
)
|
||||
|
||||
return await rag.get_knowledge_graph(
|
||||
node_label=label,
|
||||
max_depth=max_depth,
|
||||
max_nodes=max_nodes,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting knowledge graph for label '{label}': {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error getting knowledge graph: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/graph/entity/exists", dependencies=[Depends(combined_auth)])
|
||||
async def check_entity_exists(
|
||||
name: str = Query(..., description="Entity name to check"),
|
||||
):
|
||||
"""
|
||||
Check if an entity with the given name exists in the knowledge graph
|
||||
|
||||
Args:
|
||||
name (str): Name of the entity to check
|
||||
|
||||
Returns:
|
||||
Dict[str, bool]: Dictionary with 'exists' key indicating if entity exists
|
||||
"""
|
||||
try:
|
||||
exists = await rag.chunk_entity_relation_graph.has_node(name)
|
||||
return {"exists": exists}
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking entity existence for '{name}': {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error checking entity existence: {str(e)}"
|
||||
)
|
||||
|
||||
@router.post("/graph/entity/edit", dependencies=[Depends(combined_auth)])
|
||||
async def update_entity(request: EntityUpdateRequest):
|
||||
"""
|
||||
Update an entity's properties in the knowledge graph
|
||||
|
||||
This endpoint allows updating entity properties, including renaming entities.
|
||||
When renaming to an existing entity name, the behavior depends on allow_merge:
|
||||
|
||||
Args:
|
||||
request (EntityUpdateRequest): Request containing:
|
||||
- entity_name (str): Name of the entity to update
|
||||
- updated_data (Dict[str, Any]): Dictionary of properties to update
|
||||
- allow_rename (bool): Whether to allow entity renaming (default: False)
|
||||
- allow_merge (bool): Whether to merge into existing entity when renaming
|
||||
causes name conflict (default: False)
|
||||
|
||||
Returns:
|
||||
Dict with the following structure:
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Entity updated successfully" | "Entity merged successfully into 'target_name'",
|
||||
"data": {
|
||||
"entity_name": str, # Final entity name
|
||||
"description": str, # Entity description
|
||||
"entity_type": str, # Entity type
|
||||
"source_id": str, # Source chunk IDs
|
||||
... # Other entity properties
|
||||
},
|
||||
"operation_summary": {
|
||||
"merged": bool, # Whether entity was merged into another
|
||||
"merge_status": str, # "success" | "failed" | "not_attempted"
|
||||
"merge_error": str | None, # Error message if merge failed
|
||||
"operation_status": str, # "success" | "partial_success" | "failure"
|
||||
"target_entity": str | None, # Target entity name if renaming/merging
|
||||
"final_entity": str, # Final entity name after operation
|
||||
"renamed": bool # Whether entity was renamed
|
||||
}
|
||||
}
|
||||
|
||||
operation_status values explained:
|
||||
- "success": All operations completed successfully
|
||||
* For simple updates: entity properties updated
|
||||
* For renames: entity renamed successfully
|
||||
* For merges: non-name updates applied AND merge completed
|
||||
|
||||
- "partial_success": Update succeeded but merge failed
|
||||
* Non-name property updates were applied successfully
|
||||
* Merge operation failed (entity not merged)
|
||||
* Original entity still exists with updated properties
|
||||
* Use merge_error for failure details
|
||||
|
||||
- "failure": Operation failed completely
|
||||
* If merge_status == "failed": Merge attempted but both update and merge failed
|
||||
* If merge_status == "not_attempted": Regular update failed
|
||||
* No changes were applied to the entity
|
||||
|
||||
merge_status values explained:
|
||||
- "success": Entity successfully merged into target entity
|
||||
- "failed": Merge operation was attempted but failed
|
||||
- "not_attempted": No merge was attempted (normal update/rename)
|
||||
|
||||
Behavior when renaming to an existing entity:
|
||||
- If allow_merge=False: Raises ValueError with 400 status (default behavior)
|
||||
- If allow_merge=True: Automatically merges the source entity into the existing target entity,
|
||||
preserving all relationships and applying non-name updates first
|
||||
|
||||
Example Request (simple update):
|
||||
POST /graph/entity/edit
|
||||
{
|
||||
"entity_name": "Tesla",
|
||||
"updated_data": {"description": "Updated description"},
|
||||
"allow_rename": false,
|
||||
"allow_merge": false
|
||||
}
|
||||
|
||||
Example Response (simple update success):
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Entity updated successfully",
|
||||
"data": { ... },
|
||||
"operation_summary": {
|
||||
"merged": false,
|
||||
"merge_status": "not_attempted",
|
||||
"merge_error": null,
|
||||
"operation_status": "success",
|
||||
"target_entity": null,
|
||||
"final_entity": "Tesla",
|
||||
"renamed": false
|
||||
}
|
||||
}
|
||||
|
||||
Example Request (rename with auto-merge):
|
||||
POST /graph/entity/edit
|
||||
{
|
||||
"entity_name": "Elon Msk",
|
||||
"updated_data": {
|
||||
"entity_name": "Elon Musk",
|
||||
"description": "Corrected description"
|
||||
},
|
||||
"allow_rename": true,
|
||||
"allow_merge": true
|
||||
}
|
||||
|
||||
Example Response (merge success):
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Entity merged successfully into 'Elon Musk'",
|
||||
"data": { ... },
|
||||
"operation_summary": {
|
||||
"merged": true,
|
||||
"merge_status": "success",
|
||||
"merge_error": null,
|
||||
"operation_status": "success",
|
||||
"target_entity": "Elon Musk",
|
||||
"final_entity": "Elon Musk",
|
||||
"renamed": true
|
||||
}
|
||||
}
|
||||
|
||||
Example Response (partial success - update succeeded but merge failed):
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Entity updated successfully",
|
||||
"data": { ... }, # Data reflects updated "Elon Msk" entity
|
||||
"operation_summary": {
|
||||
"merged": false,
|
||||
"merge_status": "failed",
|
||||
"merge_error": "Target entity locked by another operation",
|
||||
"operation_status": "partial_success",
|
||||
"target_entity": "Elon Musk",
|
||||
"final_entity": "Elon Msk", # Original entity still exists
|
||||
"renamed": true
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
result = await rag.aedit_entity(
|
||||
entity_name=request.entity_name,
|
||||
updated_data=request.updated_data,
|
||||
allow_rename=request.allow_rename,
|
||||
allow_merge=request.allow_merge,
|
||||
)
|
||||
|
||||
# Extract operation_summary from result, with fallback for backward compatibility
|
||||
operation_summary = result.get(
|
||||
"operation_summary",
|
||||
{
|
||||
"merged": False,
|
||||
"merge_status": "not_attempted",
|
||||
"merge_error": None,
|
||||
"operation_status": "success",
|
||||
"target_entity": None,
|
||||
"final_entity": request.updated_data.get(
|
||||
"entity_name", request.entity_name
|
||||
),
|
||||
"renamed": request.updated_data.get(
|
||||
"entity_name", request.entity_name
|
||||
)
|
||||
!= request.entity_name,
|
||||
},
|
||||
)
|
||||
|
||||
# Separate entity data from operation_summary for clean response
|
||||
entity_data = dict(result)
|
||||
entity_data.pop("operation_summary", None)
|
||||
|
||||
# Generate appropriate response message based on merge status
|
||||
response_message = (
|
||||
f"Entity merged successfully into '{operation_summary['final_entity']}'"
|
||||
if operation_summary.get("merged")
|
||||
else "Entity updated successfully"
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": response_message,
|
||||
"data": entity_data,
|
||||
"operation_summary": operation_summary,
|
||||
}
|
||||
except ValueError as ve:
|
||||
logger.error(
|
||||
f"Validation error updating entity '{request.entity_name}': {str(ve)}"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=str(ve))
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating entity '{request.entity_name}': {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error updating entity: {str(e)}"
|
||||
)
|
||||
|
||||
@router.post("/graph/relation/edit", dependencies=[Depends(combined_auth)])
|
||||
async def update_relation(request: RelationUpdateRequest):
|
||||
"""Update a relation's properties in the knowledge graph
|
||||
|
||||
Args:
|
||||
request (RelationUpdateRequest): Request containing source ID, target ID and updated data
|
||||
|
||||
Returns:
|
||||
Dict: Updated relation information
|
||||
"""
|
||||
try:
|
||||
result = await rag.aedit_relation(
|
||||
source_entity=request.source_id,
|
||||
target_entity=request.target_id,
|
||||
updated_data=request.updated_data,
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Relation updated successfully",
|
||||
"data": result,
|
||||
}
|
||||
except ValueError as ve:
|
||||
logger.error(
|
||||
f"Validation error updating relation between '{request.source_id}' and '{request.target_id}': {str(ve)}"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=str(ve))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error updating relation between '{request.source_id}' and '{request.target_id}': {str(e)}"
|
||||
)
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error updating relation: {str(e)}"
|
||||
)
|
||||
|
||||
@router.post("/graph/entity/create", dependencies=[Depends(combined_auth)])
|
||||
async def create_entity(request: EntityCreateRequest):
|
||||
"""
|
||||
Create a new entity in the knowledge graph
|
||||
|
||||
This endpoint creates a new entity node in the knowledge graph with the specified
|
||||
properties. The system automatically generates vector embeddings for the entity
|
||||
to enable semantic search and retrieval.
|
||||
|
||||
Request Body:
|
||||
entity_name (str): Unique name identifier for the entity
|
||||
entity_data (dict): Entity properties including:
|
||||
- description (str): Textual description of the entity
|
||||
- entity_type (str): Category/type of the entity (e.g., PERSON, ORGANIZATION, LOCATION)
|
||||
- source_id (str): Related chunk_id from which the description originates
|
||||
- Additional custom properties as needed
|
||||
|
||||
Response Schema:
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Entity 'Tesla' created successfully",
|
||||
"data": {
|
||||
"entity_name": "Tesla",
|
||||
"description": "Electric vehicle manufacturer",
|
||||
"entity_type": "ORGANIZATION",
|
||||
"source_id": "chunk-123<SEP>chunk-456"
|
||||
... (other entity properties)
|
||||
}
|
||||
}
|
||||
|
||||
HTTP Status Codes:
|
||||
200: Entity created successfully
|
||||
400: Invalid request (e.g., missing required fields, duplicate entity)
|
||||
500: Internal server error
|
||||
|
||||
Example Request:
|
||||
POST /graph/entity/create
|
||||
{
|
||||
"entity_name": "Tesla",
|
||||
"entity_data": {
|
||||
"description": "Electric vehicle manufacturer",
|
||||
"entity_type": "ORGANIZATION"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Use the proper acreate_entity method which handles:
|
||||
# - Graph lock for concurrency
|
||||
# - Vector embedding creation in entities_vdb
|
||||
# - Metadata population and defaults
|
||||
# - Index consistency via _edit_entity_done
|
||||
result = await rag.acreate_entity(
|
||||
entity_name=request.entity_name,
|
||||
entity_data=request.entity_data,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Entity '{request.entity_name}' created successfully",
|
||||
"data": result,
|
||||
}
|
||||
except ValueError as ve:
|
||||
logger.error(
|
||||
f"Validation error creating entity '{request.entity_name}': {str(ve)}"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=str(ve))
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating entity '{request.entity_name}': {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error creating entity: {str(e)}"
|
||||
)
|
||||
|
||||
@router.post("/graph/relation/create", dependencies=[Depends(combined_auth)])
|
||||
async def create_relation(request: RelationCreateRequest):
|
||||
"""
|
||||
Create a new relationship between two entities in the knowledge graph
|
||||
|
||||
This endpoint establishes an undirected relationship between two existing entities.
|
||||
The provided source/target order is accepted for convenience, but the backend
|
||||
stored edge is undirected and may be returned with the entities swapped.
|
||||
Both entities must already exist in the knowledge graph. The system automatically
|
||||
generates vector embeddings for the relationship to enable semantic search and graph traversal.
|
||||
|
||||
Prerequisites:
|
||||
- Both source_entity and target_entity must exist in the knowledge graph
|
||||
- Use /graph/entity/create to create entities first if they don't exist
|
||||
|
||||
Request Body:
|
||||
source_entity (str): Name of the source entity (relationship origin)
|
||||
target_entity (str): Name of the target entity (relationship destination)
|
||||
relation_data (dict): Relationship properties including:
|
||||
- description (str): Textual description of the relationship
|
||||
- keywords (str): Comma-separated keywords describing the relationship type
|
||||
- source_id (str): Related chunk_id from which the description originates
|
||||
- weight (float): Relationship strength/importance (default: 1.0)
|
||||
- Additional custom properties as needed
|
||||
|
||||
Response Schema:
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Relation created successfully between 'Elon Musk' and 'Tesla'",
|
||||
"data": {
|
||||
"src_id": "Elon Musk",
|
||||
"tgt_id": "Tesla",
|
||||
"description": "Elon Musk is the CEO of Tesla",
|
||||
"keywords": "CEO, founder",
|
||||
"source_id": "chunk-123<SEP>chunk-456"
|
||||
"weight": 1.0,
|
||||
... (other relationship properties)
|
||||
}
|
||||
}
|
||||
|
||||
HTTP Status Codes:
|
||||
200: Relationship created successfully
|
||||
400: Invalid request (e.g., missing entities, invalid data, duplicate relationship)
|
||||
500: Internal server error
|
||||
|
||||
Example Request:
|
||||
POST /graph/relation/create
|
||||
{
|
||||
"source_entity": "Elon Musk",
|
||||
"target_entity": "Tesla",
|
||||
"relation_data": {
|
||||
"description": "Elon Musk is the CEO of Tesla",
|
||||
"keywords": "CEO, founder",
|
||||
"weight": 1.0
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Use the proper acreate_relation method which handles:
|
||||
# - Graph lock for concurrency
|
||||
# - Entity existence validation
|
||||
# - Duplicate relation checks
|
||||
# - Vector embedding creation in relationships_vdb
|
||||
# - Index consistency via _edit_relation_done
|
||||
result = await rag.acreate_relation(
|
||||
source_entity=request.source_entity,
|
||||
target_entity=request.target_entity,
|
||||
relation_data=request.relation_data,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Relation created successfully between '{request.source_entity}' and '{request.target_entity}'",
|
||||
"data": result,
|
||||
}
|
||||
except ValueError as ve:
|
||||
logger.error(
|
||||
f"Validation error creating relation between '{request.source_entity}' and '{request.target_entity}': {str(ve)}"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=str(ve))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error creating relation between '{request.source_entity}' and '{request.target_entity}': {str(e)}"
|
||||
)
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error creating relation: {str(e)}"
|
||||
)
|
||||
|
||||
@router.post("/graph/entities/merge", dependencies=[Depends(combined_auth)])
|
||||
async def merge_entities(request: EntityMergeRequest):
|
||||
"""
|
||||
Merge multiple entities into a single entity, preserving all relationships
|
||||
|
||||
This endpoint consolidates duplicate or misspelled entities while preserving the entire
|
||||
graph structure. It's particularly useful for cleaning up knowledge graphs after document
|
||||
processing or correcting entity name variations.
|
||||
|
||||
What the Merge Operation Does:
|
||||
1. Deletes the specified source entities from the knowledge graph
|
||||
2. Transfers all relationships from source entities to the target entity
|
||||
3. Intelligently merges duplicate relationships (if multiple sources have the same relationship)
|
||||
4. Updates vector embeddings for accurate retrieval and search
|
||||
5. Preserves the complete graph structure and connectivity
|
||||
6. Maintains relationship properties and metadata
|
||||
|
||||
Use Cases:
|
||||
- Fixing spelling errors in entity names (e.g., "Elon Msk" -> "Elon Musk")
|
||||
- Consolidating duplicate entities discovered after document processing
|
||||
- Merging name variations (e.g., "NY", "New York", "New York City")
|
||||
- Cleaning up the knowledge graph for better query performance
|
||||
- Standardizing entity names across the knowledge base
|
||||
|
||||
Request Body:
|
||||
entities_to_change (list[str]): List of entity names to be merged and deleted
|
||||
entity_to_change_into (str): Target entity that will receive all relationships
|
||||
|
||||
Response Schema:
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Successfully merged 2 entities into 'Elon Musk'",
|
||||
"data": {
|
||||
"merged_entity": "Elon Musk",
|
||||
"deleted_entities": ["Elon Msk", "Ellon Musk"],
|
||||
"relationships_transferred": 15,
|
||||
... (merge operation details)
|
||||
}
|
||||
}
|
||||
|
||||
HTTP Status Codes:
|
||||
200: Entities merged successfully
|
||||
400: Invalid request (e.g., empty entity list, target entity doesn't exist)
|
||||
500: Internal server error
|
||||
|
||||
Example Request:
|
||||
POST /graph/entities/merge
|
||||
{
|
||||
"entities_to_change": ["Elon Msk", "Ellon Musk"],
|
||||
"entity_to_change_into": "Elon Musk"
|
||||
}
|
||||
|
||||
Note:
|
||||
- The target entity (entity_to_change_into) must exist in the knowledge graph
|
||||
- Source entities will be permanently deleted after the merge
|
||||
- This operation cannot be undone, so verify entity names before merging
|
||||
"""
|
||||
try:
|
||||
result = await rag.amerge_entities(
|
||||
source_entities=request.entities_to_change,
|
||||
target_entity=request.entity_to_change_into,
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Successfully merged {len(request.entities_to_change)} entities into '{request.entity_to_change_into}'",
|
||||
"data": result,
|
||||
}
|
||||
except ValueError as ve:
|
||||
logger.error(
|
||||
f"Validation error merging entities {request.entities_to_change} into '{request.entity_to_change_into}': {str(ve)}"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=str(ve))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error merging entities {request.entities_to_change} into '{request.entity_to_change_into}': {str(e)}"
|
||||
)
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error merging entities: {str(e)}"
|
||||
)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,723 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Dict, Any, Optional, Type
|
||||
from lightrag.utils import logger
|
||||
import time
|
||||
import json
|
||||
import re
|
||||
from enum import Enum
|
||||
from fastapi.responses import StreamingResponse
|
||||
import asyncio
|
||||
from lightrag import LightRAG, QueryParam
|
||||
from lightrag.utils import TiktokenTokenizer
|
||||
from lightrag.api.utils_api import get_combined_auth_dependency
|
||||
from fastapi import Depends
|
||||
|
||||
|
||||
# query mode according to query prefix (bypass is not LightRAG quer mode)
|
||||
class SearchMode(str, Enum):
|
||||
naive = "naive"
|
||||
local = "local"
|
||||
global_ = "global"
|
||||
hybrid = "hybrid"
|
||||
mix = "mix"
|
||||
bypass = "bypass"
|
||||
context = "context"
|
||||
|
||||
|
||||
class OllamaMessage(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
images: Optional[List[str]] = None
|
||||
|
||||
|
||||
class OllamaChatRequest(BaseModel):
|
||||
model: str
|
||||
messages: List[OllamaMessage]
|
||||
stream: bool = True
|
||||
options: Optional[Dict[str, Any]] = None
|
||||
system: Optional[str] = None
|
||||
|
||||
|
||||
class OllamaChatResponse(BaseModel):
|
||||
model: str
|
||||
created_at: str
|
||||
message: OllamaMessage
|
||||
done: bool
|
||||
|
||||
|
||||
class OllamaGenerateRequest(BaseModel):
|
||||
model: str
|
||||
prompt: str
|
||||
system: Optional[str] = None
|
||||
stream: bool = False
|
||||
options: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class OllamaGenerateResponse(BaseModel):
|
||||
model: str
|
||||
created_at: str
|
||||
response: str
|
||||
done: bool
|
||||
context: Optional[List[int]]
|
||||
total_duration: Optional[int]
|
||||
load_duration: Optional[int]
|
||||
prompt_eval_count: Optional[int]
|
||||
prompt_eval_duration: Optional[int]
|
||||
eval_count: Optional[int]
|
||||
eval_duration: Optional[int]
|
||||
|
||||
|
||||
class OllamaVersionResponse(BaseModel):
|
||||
version: str
|
||||
|
||||
|
||||
class OllamaModelDetails(BaseModel):
|
||||
parent_model: str
|
||||
format: str
|
||||
family: str
|
||||
families: List[str]
|
||||
parameter_size: str
|
||||
quantization_level: str
|
||||
|
||||
|
||||
class OllamaModel(BaseModel):
|
||||
name: str
|
||||
model: str
|
||||
size: int
|
||||
digest: str
|
||||
modified_at: str
|
||||
details: OllamaModelDetails
|
||||
|
||||
|
||||
class OllamaTagResponse(BaseModel):
|
||||
models: List[OllamaModel]
|
||||
|
||||
|
||||
class OllamaRunningModelDetails(BaseModel):
|
||||
parent_model: str
|
||||
format: str
|
||||
family: str
|
||||
families: List[str]
|
||||
parameter_size: str
|
||||
quantization_level: str
|
||||
|
||||
|
||||
class OllamaRunningModel(BaseModel):
|
||||
name: str
|
||||
model: str
|
||||
size: int
|
||||
digest: str
|
||||
details: OllamaRunningModelDetails
|
||||
expires_at: str
|
||||
size_vram: int
|
||||
|
||||
|
||||
class OllamaPsResponse(BaseModel):
|
||||
models: List[OllamaRunningModel]
|
||||
|
||||
|
||||
async def parse_request_body(
|
||||
request: Request, model_class: Type[BaseModel]
|
||||
) -> BaseModel:
|
||||
"""
|
||||
Parse request body based on Content-Type header.
|
||||
Supports both application/json and application/octet-stream.
|
||||
|
||||
Args:
|
||||
request: The FastAPI Request object
|
||||
model_class: The Pydantic model class to parse the request into
|
||||
|
||||
Returns:
|
||||
An instance of the provided model_class
|
||||
"""
|
||||
content_type = request.headers.get("content-type", "").lower()
|
||||
|
||||
try:
|
||||
if content_type.startswith("application/json"):
|
||||
# FastAPI already handles JSON parsing for us
|
||||
body = await request.json()
|
||||
elif content_type.startswith("application/octet-stream"):
|
||||
# Manually parse octet-stream as JSON
|
||||
body_bytes = await request.body()
|
||||
body = json.loads(body_bytes.decode("utf-8"))
|
||||
else:
|
||||
# Try to parse as JSON for any other content type
|
||||
body_bytes = await request.body()
|
||||
body = json.loads(body_bytes.decode("utf-8"))
|
||||
|
||||
# Create an instance of the model
|
||||
return model_class(**body)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON in request body")
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Error parsing request body: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""Estimate the number of tokens in text using tiktoken"""
|
||||
tokens = TiktokenTokenizer().encode(text)
|
||||
return len(tokens)
|
||||
|
||||
|
||||
def parse_query_mode(query: str) -> tuple[str, SearchMode, bool, Optional[str]]:
|
||||
"""Parse query prefix to determine search mode
|
||||
Returns tuple of (cleaned_query, search_mode, only_need_context, user_prompt)
|
||||
|
||||
Examples:
|
||||
- "/local[use mermaid format for diagrams] query string" -> (cleaned_query, SearchMode.local, False, "use mermaid format for diagrams")
|
||||
- "/[use mermaid format for diagrams] query string" -> (cleaned_query, SearchMode.hybrid, False, "use mermaid format for diagrams")
|
||||
- "/local query string" -> (cleaned_query, SearchMode.local, False, None)
|
||||
"""
|
||||
# Initialize user_prompt as None
|
||||
user_prompt = None
|
||||
|
||||
# First check if there's a bracket format for user prompt
|
||||
bracket_pattern = r"^/([a-z]*)\[(.*?)\](.*)"
|
||||
bracket_match = re.match(bracket_pattern, query)
|
||||
|
||||
if bracket_match:
|
||||
mode_prefix = bracket_match.group(1)
|
||||
user_prompt = bracket_match.group(2)
|
||||
remaining_query = bracket_match.group(3).lstrip()
|
||||
|
||||
# Reconstruct query, removing the bracket part
|
||||
query = f"/{mode_prefix} {remaining_query}".strip()
|
||||
|
||||
# Unified handling of mode and only_need_context determination
|
||||
mode_map = {
|
||||
"/local ": (SearchMode.local, False),
|
||||
"/global ": (
|
||||
SearchMode.global_,
|
||||
False,
|
||||
), # global_ is used because 'global' is a Python keyword
|
||||
"/naive ": (SearchMode.naive, False),
|
||||
"/hybrid ": (SearchMode.hybrid, False),
|
||||
"/mix ": (SearchMode.mix, False),
|
||||
"/bypass ": (SearchMode.bypass, False),
|
||||
"/context": (
|
||||
SearchMode.mix,
|
||||
True,
|
||||
),
|
||||
"/localcontext": (SearchMode.local, True),
|
||||
"/globalcontext": (SearchMode.global_, True),
|
||||
"/hybridcontext": (SearchMode.hybrid, True),
|
||||
"/naivecontext": (SearchMode.naive, True),
|
||||
"/mixcontext": (SearchMode.mix, True),
|
||||
}
|
||||
|
||||
for prefix, (mode, only_need_context) in mode_map.items():
|
||||
if query.startswith(prefix):
|
||||
# After removing prefix and leading spaces
|
||||
cleaned_query = query[len(prefix) :].lstrip()
|
||||
return cleaned_query, mode, only_need_context, user_prompt
|
||||
|
||||
return query, SearchMode.mix, False, user_prompt
|
||||
|
||||
|
||||
class OllamaAPI:
|
||||
def __init__(self, rag: LightRAG, top_k: int = 60, api_key: Optional[str] = None):
|
||||
self.rag = rag
|
||||
self.ollama_server_infos = rag.ollama_server_infos
|
||||
self.top_k = top_k
|
||||
self.api_key = api_key
|
||||
self.router = APIRouter(tags=["ollama"])
|
||||
self.setup_routes()
|
||||
|
||||
def setup_routes(self):
|
||||
# Create combined auth dependency for Ollama API routes
|
||||
combined_auth = get_combined_auth_dependency(self.api_key)
|
||||
|
||||
@self.router.get("/version", dependencies=[Depends(combined_auth)])
|
||||
async def get_version():
|
||||
"""Get Ollama version information"""
|
||||
return OllamaVersionResponse(version="0.9.3")
|
||||
|
||||
@self.router.get("/tags", dependencies=[Depends(combined_auth)])
|
||||
async def get_tags():
|
||||
"""Return available models acting as an Ollama server"""
|
||||
return OllamaTagResponse(
|
||||
models=[
|
||||
{
|
||||
"name": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"modified_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"size": self.ollama_server_infos.LIGHTRAG_SIZE,
|
||||
"digest": self.ollama_server_infos.LIGHTRAG_DIGEST,
|
||||
"details": {
|
||||
"parent_model": "",
|
||||
"format": "gguf",
|
||||
"family": self.ollama_server_infos.LIGHTRAG_NAME,
|
||||
"families": [self.ollama_server_infos.LIGHTRAG_NAME],
|
||||
"parameter_size": "13B",
|
||||
"quantization_level": "Q4_0",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
@self.router.get("/ps", dependencies=[Depends(combined_auth)])
|
||||
async def get_running_models():
|
||||
"""List Running Models - returns currently running models"""
|
||||
return OllamaPsResponse(
|
||||
models=[
|
||||
{
|
||||
"name": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"size": self.ollama_server_infos.LIGHTRAG_SIZE,
|
||||
"digest": self.ollama_server_infos.LIGHTRAG_DIGEST,
|
||||
"details": {
|
||||
"parent_model": "",
|
||||
"format": "gguf",
|
||||
"family": "llama",
|
||||
"families": ["llama"],
|
||||
"parameter_size": "7.2B",
|
||||
"quantization_level": "Q4_0",
|
||||
},
|
||||
"expires_at": "2050-12-31T14:38:31.83753-07:00",
|
||||
"size_vram": self.ollama_server_infos.LIGHTRAG_SIZE,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
@self.router.post(
|
||||
"/generate", dependencies=[Depends(combined_auth)], include_in_schema=True
|
||||
)
|
||||
async def generate(raw_request: Request):
|
||||
"""Handle generate completion requests acting as an Ollama model
|
||||
For compatibility purpose, the request is not processed by LightRAG,
|
||||
and will be handled by underlying LLM model.
|
||||
Supports both application/json and application/octet-stream Content-Types.
|
||||
"""
|
||||
try:
|
||||
# Parse the request body manually
|
||||
request = await parse_request_body(raw_request, OllamaGenerateRequest)
|
||||
|
||||
query = request.prompt
|
||||
start_time = time.time_ns()
|
||||
prompt_tokens = estimate_tokens(query)
|
||||
|
||||
if request.system:
|
||||
self.rag.llm_model_kwargs["system_prompt"] = request.system
|
||||
|
||||
if request.stream:
|
||||
response = await self.rag.llm_model_func(
|
||||
query, stream=True, **self.rag.llm_model_kwargs
|
||||
)
|
||||
|
||||
async def stream_generator():
|
||||
first_chunk_time = None
|
||||
last_chunk_time = time.time_ns()
|
||||
total_response = ""
|
||||
|
||||
# Ensure response is an async generator
|
||||
if isinstance(response, str):
|
||||
# If it's a string, send in two parts
|
||||
first_chunk_time = start_time
|
||||
last_chunk_time = time.time_ns()
|
||||
total_response = response
|
||||
|
||||
data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"response": response,
|
||||
"done": False,
|
||||
}
|
||||
yield f"{json.dumps(data, ensure_ascii=False)}\n"
|
||||
|
||||
completion_tokens = estimate_tokens(total_response)
|
||||
total_time = last_chunk_time - start_time
|
||||
prompt_eval_time = first_chunk_time - start_time
|
||||
eval_time = last_chunk_time - first_chunk_time
|
||||
|
||||
data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"response": "",
|
||||
"done": True,
|
||||
"done_reason": "stop",
|
||||
"context": [],
|
||||
"total_duration": total_time,
|
||||
"load_duration": 0,
|
||||
"prompt_eval_count": prompt_tokens,
|
||||
"prompt_eval_duration": prompt_eval_time,
|
||||
"eval_count": completion_tokens,
|
||||
"eval_duration": eval_time,
|
||||
}
|
||||
yield f"{json.dumps(data, ensure_ascii=False)}\n"
|
||||
else:
|
||||
try:
|
||||
async for chunk in response:
|
||||
if chunk:
|
||||
if first_chunk_time is None:
|
||||
first_chunk_time = time.time_ns()
|
||||
|
||||
last_chunk_time = time.time_ns()
|
||||
|
||||
total_response += chunk
|
||||
data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"response": chunk,
|
||||
"done": False,
|
||||
}
|
||||
yield f"{json.dumps(data, ensure_ascii=False)}\n"
|
||||
except (asyncio.CancelledError, Exception) as e:
|
||||
error_msg = str(e)
|
||||
if isinstance(e, asyncio.CancelledError):
|
||||
error_msg = "Stream was cancelled by server"
|
||||
else:
|
||||
error_msg = f"Provider error: {error_msg}"
|
||||
|
||||
logger.error(f"Stream error: {error_msg}")
|
||||
|
||||
# Send error message to client
|
||||
error_data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"response": f"\n\nError: {error_msg}",
|
||||
"error": f"\n\nError: {error_msg}",
|
||||
"done": False,
|
||||
}
|
||||
yield f"{json.dumps(error_data, ensure_ascii=False)}\n"
|
||||
|
||||
# Send final message to close the stream
|
||||
final_data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"response": "",
|
||||
"done": True,
|
||||
}
|
||||
yield f"{json.dumps(final_data, ensure_ascii=False)}\n"
|
||||
return
|
||||
if first_chunk_time is None:
|
||||
first_chunk_time = start_time
|
||||
completion_tokens = estimate_tokens(total_response)
|
||||
total_time = last_chunk_time - start_time
|
||||
prompt_eval_time = first_chunk_time - start_time
|
||||
eval_time = last_chunk_time - first_chunk_time
|
||||
|
||||
data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"response": "",
|
||||
"done": True,
|
||||
"done_reason": "stop",
|
||||
"context": [],
|
||||
"total_duration": total_time,
|
||||
"load_duration": 0,
|
||||
"prompt_eval_count": prompt_tokens,
|
||||
"prompt_eval_duration": prompt_eval_time,
|
||||
"eval_count": completion_tokens,
|
||||
"eval_duration": eval_time,
|
||||
}
|
||||
yield f"{json.dumps(data, ensure_ascii=False)}\n"
|
||||
return
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Content-Type": "application/x-ndjson",
|
||||
"X-Accel-Buffering": "no", # Ensure proper handling of streaming responses in Nginx proxy
|
||||
},
|
||||
)
|
||||
else:
|
||||
first_chunk_time = time.time_ns()
|
||||
response_text = await self.rag.llm_model_func(
|
||||
query, stream=False, **self.rag.llm_model_kwargs
|
||||
)
|
||||
last_chunk_time = time.time_ns()
|
||||
|
||||
if not response_text:
|
||||
response_text = "No response generated"
|
||||
|
||||
completion_tokens = estimate_tokens(str(response_text))
|
||||
total_time = last_chunk_time - start_time
|
||||
prompt_eval_time = first_chunk_time - start_time
|
||||
eval_time = last_chunk_time - first_chunk_time
|
||||
|
||||
return {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"response": str(response_text),
|
||||
"done": True,
|
||||
"done_reason": "stop",
|
||||
"context": [],
|
||||
"total_duration": total_time,
|
||||
"load_duration": 0,
|
||||
"prompt_eval_count": prompt_tokens,
|
||||
"prompt_eval_duration": prompt_eval_time,
|
||||
"eval_count": completion_tokens,
|
||||
"eval_duration": eval_time,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama generate error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@self.router.post(
|
||||
"/chat", dependencies=[Depends(combined_auth)], include_in_schema=True
|
||||
)
|
||||
async def chat(raw_request: Request):
|
||||
"""Process chat completion requests by acting as an Ollama model.
|
||||
Routes user queries through LightRAG by selecting query mode based on query prefix.
|
||||
Detects and forwards OpenWebUI session-related requests (for meta data generation task) directly to LLM.
|
||||
Supports both application/json and application/octet-stream Content-Types.
|
||||
"""
|
||||
try:
|
||||
# Parse the request body manually
|
||||
request = await parse_request_body(raw_request, OllamaChatRequest)
|
||||
|
||||
# Get all messages
|
||||
messages = request.messages
|
||||
if not messages:
|
||||
raise HTTPException(status_code=400, detail="No messages provided")
|
||||
|
||||
# Validate that the last message is from a user
|
||||
if messages[-1].role != "user":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Last message must be from user role"
|
||||
)
|
||||
|
||||
# Get the last message as query and previous messages as history
|
||||
query = messages[-1].content
|
||||
# Convert OllamaMessage objects to dictionaries
|
||||
conversation_history = [
|
||||
{"role": msg.role, "content": msg.content} for msg in messages[:-1]
|
||||
]
|
||||
|
||||
# Check for query prefix
|
||||
cleaned_query, mode, only_need_context, user_prompt = parse_query_mode(
|
||||
query
|
||||
)
|
||||
|
||||
start_time = time.time_ns()
|
||||
prompt_tokens = estimate_tokens(cleaned_query)
|
||||
|
||||
param_dict = {
|
||||
"mode": mode.value,
|
||||
"stream": request.stream,
|
||||
"only_need_context": only_need_context,
|
||||
"conversation_history": conversation_history,
|
||||
"top_k": self.top_k,
|
||||
}
|
||||
|
||||
# Add user_prompt to param_dict
|
||||
if user_prompt is not None:
|
||||
param_dict["user_prompt"] = user_prompt
|
||||
|
||||
query_param = QueryParam(**param_dict)
|
||||
|
||||
if request.stream:
|
||||
# Determine if the request is prefix with "/bypass"
|
||||
if mode == SearchMode.bypass:
|
||||
if request.system:
|
||||
self.rag.llm_model_kwargs["system_prompt"] = request.system
|
||||
response = await self.rag.llm_model_func(
|
||||
cleaned_query,
|
||||
stream=True,
|
||||
history_messages=conversation_history,
|
||||
**self.rag.llm_model_kwargs,
|
||||
)
|
||||
else:
|
||||
response = await self.rag.aquery(
|
||||
cleaned_query, param=query_param
|
||||
)
|
||||
|
||||
async def stream_generator():
|
||||
first_chunk_time = None
|
||||
last_chunk_time = time.time_ns()
|
||||
total_response = ""
|
||||
|
||||
# Ensure response is an async generator
|
||||
if isinstance(response, str):
|
||||
# If it's a string, send in two parts
|
||||
first_chunk_time = start_time
|
||||
last_chunk_time = time.time_ns()
|
||||
total_response = response
|
||||
|
||||
data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": response,
|
||||
"images": None,
|
||||
},
|
||||
"done": False,
|
||||
}
|
||||
yield f"{json.dumps(data, ensure_ascii=False)}\n"
|
||||
|
||||
completion_tokens = estimate_tokens(total_response)
|
||||
total_time = last_chunk_time - start_time
|
||||
prompt_eval_time = first_chunk_time - start_time
|
||||
eval_time = last_chunk_time - first_chunk_time
|
||||
|
||||
data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"images": None,
|
||||
},
|
||||
"done_reason": "stop",
|
||||
"done": True,
|
||||
"total_duration": total_time,
|
||||
"load_duration": 0,
|
||||
"prompt_eval_count": prompt_tokens,
|
||||
"prompt_eval_duration": prompt_eval_time,
|
||||
"eval_count": completion_tokens,
|
||||
"eval_duration": eval_time,
|
||||
}
|
||||
yield f"{json.dumps(data, ensure_ascii=False)}\n"
|
||||
else:
|
||||
try:
|
||||
async for chunk in response:
|
||||
if chunk:
|
||||
if first_chunk_time is None:
|
||||
first_chunk_time = time.time_ns()
|
||||
|
||||
last_chunk_time = time.time_ns()
|
||||
|
||||
total_response += chunk
|
||||
data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": chunk,
|
||||
"images": None,
|
||||
},
|
||||
"done": False,
|
||||
}
|
||||
yield f"{json.dumps(data, ensure_ascii=False)}\n"
|
||||
except (asyncio.CancelledError, Exception) as e:
|
||||
error_msg = str(e)
|
||||
if isinstance(e, asyncio.CancelledError):
|
||||
error_msg = "Stream was cancelled by server"
|
||||
else:
|
||||
error_msg = f"Provider error: {error_msg}"
|
||||
|
||||
logger.error(f"Stream error: {error_msg}")
|
||||
|
||||
# Send error message to client
|
||||
error_data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": f"\n\nError: {error_msg}",
|
||||
"images": None,
|
||||
},
|
||||
"error": f"\n\nError: {error_msg}",
|
||||
"done": False,
|
||||
}
|
||||
yield f"{json.dumps(error_data, ensure_ascii=False)}\n"
|
||||
|
||||
# Send final message to close the stream
|
||||
final_data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"images": None,
|
||||
},
|
||||
"done": True,
|
||||
}
|
||||
yield f"{json.dumps(final_data, ensure_ascii=False)}\n"
|
||||
return
|
||||
|
||||
if first_chunk_time is None:
|
||||
first_chunk_time = start_time
|
||||
completion_tokens = estimate_tokens(total_response)
|
||||
total_time = last_chunk_time - start_time
|
||||
prompt_eval_time = first_chunk_time - start_time
|
||||
eval_time = last_chunk_time - first_chunk_time
|
||||
|
||||
data = {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"images": None,
|
||||
},
|
||||
"done_reason": "stop",
|
||||
"done": True,
|
||||
"total_duration": total_time,
|
||||
"load_duration": 0,
|
||||
"prompt_eval_count": prompt_tokens,
|
||||
"prompt_eval_duration": prompt_eval_time,
|
||||
"eval_count": completion_tokens,
|
||||
"eval_duration": eval_time,
|
||||
}
|
||||
yield f"{json.dumps(data, ensure_ascii=False)}\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Content-Type": "application/x-ndjson",
|
||||
"X-Accel-Buffering": "no", # Ensure proper handling of streaming responses in Nginx proxy
|
||||
},
|
||||
)
|
||||
else:
|
||||
first_chunk_time = time.time_ns()
|
||||
|
||||
# Determine if the request is prefix with "/bypass" or from Open WebUI's session title and session keyword generation task
|
||||
match_result = re.search(
|
||||
r"\n<chat_history>\nUSER:", cleaned_query, re.MULTILINE
|
||||
)
|
||||
if match_result or mode == SearchMode.bypass:
|
||||
if request.system:
|
||||
self.rag.llm_model_kwargs["system_prompt"] = request.system
|
||||
|
||||
response_text = await self.rag.llm_model_func(
|
||||
cleaned_query,
|
||||
stream=False,
|
||||
history_messages=conversation_history,
|
||||
**self.rag.llm_model_kwargs,
|
||||
)
|
||||
else:
|
||||
response_text = await self.rag.aquery(
|
||||
cleaned_query, param=query_param
|
||||
)
|
||||
|
||||
last_chunk_time = time.time_ns()
|
||||
|
||||
if not response_text:
|
||||
response_text = "No response generated"
|
||||
|
||||
completion_tokens = estimate_tokens(str(response_text))
|
||||
total_time = last_chunk_time - start_time
|
||||
prompt_eval_time = first_chunk_time - start_time
|
||||
eval_time = last_chunk_time - first_chunk_time
|
||||
|
||||
return {
|
||||
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
|
||||
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": str(response_text),
|
||||
"images": None,
|
||||
},
|
||||
"done_reason": "stop",
|
||||
"done": True,
|
||||
"total_duration": total_time,
|
||||
"load_duration": 0,
|
||||
"prompt_eval_count": prompt_tokens,
|
||||
"prompt_eval_duration": prompt_eval_time,
|
||||
"eval_count": completion_tokens,
|
||||
"eval_duration": eval_time,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama chat error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Start LightRAG server with Gunicorn
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import pipmaster as pm
|
||||
from lightrag.api.utils_api import display_splash_screen, check_env_file
|
||||
from lightrag.api.config import global_args
|
||||
from lightrag.utils import get_env_value
|
||||
from lightrag.kg.shared_storage import initialize_share_data
|
||||
|
||||
from lightrag.constants import (
|
||||
DEFAULT_WOKERS,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def check_and_install_dependencies():
|
||||
"""Check and install required dependencies"""
|
||||
required_packages = [
|
||||
"gunicorn",
|
||||
"tiktoken",
|
||||
"psutil",
|
||||
# Add other required packages here
|
||||
]
|
||||
|
||||
for package in required_packages:
|
||||
if not pm.is_installed(package):
|
||||
print(f"Installing {package}...")
|
||||
pm.install(package)
|
||||
print(f"{package} installed successfully")
|
||||
|
||||
|
||||
def main():
|
||||
# Explicitly initialize configuration for Gunicorn mode
|
||||
from lightrag.api.config import initialize_config
|
||||
|
||||
initialize_config()
|
||||
|
||||
# Set Gunicorn mode flag for lifespan cleanup detection
|
||||
os.environ["LIGHTRAG_GUNICORN_MODE"] = "1"
|
||||
|
||||
# Check .env file
|
||||
if not check_env_file():
|
||||
sys.exit(1)
|
||||
|
||||
# Check DOCLING compatibility with Gunicorn multi-worker mode on macOS
|
||||
if (
|
||||
platform.system() == "Darwin"
|
||||
and global_args.document_loading_engine == "DOCLING"
|
||||
and global_args.workers > 1
|
||||
):
|
||||
print("\n" + "=" * 80)
|
||||
print("❌ ERROR: Incompatible configuration detected!")
|
||||
print("=" * 80)
|
||||
print(
|
||||
"\nDOCLING engine with Gunicorn multi-worker mode is not supported on macOS"
|
||||
)
|
||||
print("\nReason:")
|
||||
print(" PyTorch (required by DOCLING) has known compatibility issues with")
|
||||
print(" fork-based multiprocessing on macOS, which can cause crashes or")
|
||||
print(" unexpected behavior when using Gunicorn with multiple workers.")
|
||||
print("\nCurrent configuration:")
|
||||
print(" - Operating System: macOS (Darwin)")
|
||||
print(f" - Document Engine: {global_args.document_loading_engine}")
|
||||
print(f" - Workers: {global_args.workers}")
|
||||
print("\nPossible solutions:")
|
||||
print(" 1. Use single worker mode:")
|
||||
print(" --workers 1")
|
||||
print("\n 2. Change document loading engine in .env:")
|
||||
print(" DOCUMENT_LOADING_ENGINE=DEFAULT")
|
||||
print("\n 3. Deploy on Linux where multi-worker mode is fully supported")
|
||||
print("=" * 80 + "\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Check macOS fork safety environment variable for multi-worker mode
|
||||
if (
|
||||
platform.system() == "Darwin"
|
||||
and global_args.workers > 1
|
||||
and os.environ.get("OBJC_DISABLE_INITIALIZE_FORK_SAFETY") != "YES"
|
||||
):
|
||||
print("\n" + "=" * 80)
|
||||
print("❌ ERROR: Missing required environment variable on macOS!")
|
||||
print("=" * 80)
|
||||
print("\nmacOS with Gunicorn multi-worker mode requires:")
|
||||
print(" OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES")
|
||||
print("\nReason:")
|
||||
print(" NumPy uses macOS's Accelerate framework (Objective-C based) for")
|
||||
print(" vector computations. The Objective-C runtime has fork safety checks")
|
||||
print(" that will crash worker processes when embedding functions are called.")
|
||||
print("\nCurrent configuration:")
|
||||
print(" - Operating System: macOS (Darwin)")
|
||||
print(f" - Workers: {global_args.workers}")
|
||||
print(
|
||||
f" - Environment Variable: {os.environ.get('OBJC_DISABLE_INITIALIZE_FORK_SAFETY', 'NOT SET')}"
|
||||
)
|
||||
print("\nHow to fix:")
|
||||
print(" Option 1 - Set environment variable before starting (recommended):")
|
||||
print(" export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES")
|
||||
print(" lightrag-gunicorn --workers 2")
|
||||
print("\n Option 2 - Add to your shell profile (~/.zshrc or ~/.bash_profile):")
|
||||
print(" echo 'export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES' >> ~/.zshrc")
|
||||
print(" source ~/.zshrc")
|
||||
print("\n Option 3 - Use single worker mode (no multiprocessing):")
|
||||
print(" lightrag-server --workers 1")
|
||||
print("=" * 80 + "\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Check and install dependencies
|
||||
check_and_install_dependencies()
|
||||
|
||||
# Note: Signal handlers are NOT registered here because:
|
||||
# - Master cleanup already handled by gunicorn_config.on_exit()
|
||||
|
||||
# Display startup information
|
||||
display_splash_screen(global_args)
|
||||
|
||||
print("🚀 Starting LightRAG with Gunicorn")
|
||||
print(f"🔄 Worker management: Gunicorn (workers={global_args.workers})")
|
||||
print("🔍 Preloading app: Enabled")
|
||||
print("📝 Note: Using Gunicorn's preload feature for shared data initialization")
|
||||
print("\n\n" + "=" * 80)
|
||||
print("MAIN PROCESS INITIALIZATION")
|
||||
print(f"Process ID: {os.getpid()}")
|
||||
print(f"Workers setting: {global_args.workers}")
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
# Import Gunicorn's StandaloneApplication
|
||||
from gunicorn.app.base import BaseApplication
|
||||
|
||||
# Define a custom application class that loads our config
|
||||
class GunicornApp(BaseApplication):
|
||||
def __init__(self, app, options=None):
|
||||
self.options = options or {}
|
||||
self.application = app
|
||||
super().__init__()
|
||||
|
||||
def load_config(self):
|
||||
# Define valid Gunicorn configuration options
|
||||
valid_options = {
|
||||
"bind",
|
||||
"workers",
|
||||
"worker_class",
|
||||
"timeout",
|
||||
"keepalive",
|
||||
"preload_app",
|
||||
"errorlog",
|
||||
"accesslog",
|
||||
"loglevel",
|
||||
"certfile",
|
||||
"keyfile",
|
||||
"limit_request_line",
|
||||
"limit_request_fields",
|
||||
"limit_request_field_size",
|
||||
"graceful_timeout",
|
||||
"max_requests",
|
||||
"max_requests_jitter",
|
||||
}
|
||||
|
||||
# Special hooks that need to be set separately
|
||||
special_hooks = {
|
||||
"on_starting",
|
||||
"on_reload",
|
||||
"on_exit",
|
||||
"pre_fork",
|
||||
"post_fork",
|
||||
"pre_exec",
|
||||
"pre_request",
|
||||
"post_request",
|
||||
"worker_init",
|
||||
"worker_exit",
|
||||
"nworkers_changed",
|
||||
"child_exit",
|
||||
}
|
||||
|
||||
# Import and configure the gunicorn_config module
|
||||
from lightrag.api import gunicorn_config
|
||||
|
||||
# Set configuration variables in gunicorn_config, prioritizing command line arguments
|
||||
gunicorn_config.workers = (
|
||||
global_args.workers
|
||||
if global_args.workers
|
||||
else get_env_value("WORKERS", DEFAULT_WOKERS, int)
|
||||
)
|
||||
|
||||
# Bind configuration prioritizes command line arguments
|
||||
host = (
|
||||
global_args.host
|
||||
if global_args.host != "0.0.0.0"
|
||||
else os.getenv("HOST", "0.0.0.0")
|
||||
)
|
||||
port = (
|
||||
global_args.port
|
||||
if global_args.port != 9621
|
||||
else get_env_value("PORT", 9621, int)
|
||||
)
|
||||
gunicorn_config.bind = f"{host}:{port}"
|
||||
|
||||
# Log level configuration prioritizes command line arguments
|
||||
gunicorn_config.loglevel = (
|
||||
global_args.log_level.lower()
|
||||
if global_args.log_level
|
||||
else os.getenv("LOG_LEVEL", "info")
|
||||
)
|
||||
|
||||
# Timeout configuration prioritizes command line arguments
|
||||
gunicorn_config.timeout = (
|
||||
global_args.timeout + 30
|
||||
if global_args.timeout is not None
|
||||
else get_env_value(
|
||||
"TIMEOUT", DEFAULT_TIMEOUT + 30, int, special_none=True
|
||||
)
|
||||
)
|
||||
|
||||
# Keepalive configuration
|
||||
gunicorn_config.keepalive = get_env_value("KEEPALIVE", 5, int)
|
||||
|
||||
# SSL configuration prioritizes command line arguments
|
||||
if global_args.ssl or os.getenv("SSL", "").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
"t",
|
||||
"on",
|
||||
):
|
||||
gunicorn_config.certfile = (
|
||||
global_args.ssl_certfile
|
||||
if global_args.ssl_certfile
|
||||
else os.getenv("SSL_CERTFILE")
|
||||
)
|
||||
gunicorn_config.keyfile = (
|
||||
global_args.ssl_keyfile
|
||||
if global_args.ssl_keyfile
|
||||
else os.getenv("SSL_KEYFILE")
|
||||
)
|
||||
|
||||
# Set configuration options from the module
|
||||
for key in dir(gunicorn_config):
|
||||
if key in valid_options:
|
||||
value = getattr(gunicorn_config, key)
|
||||
# Skip functions like on_starting and None values
|
||||
if not callable(value) and value is not None:
|
||||
self.cfg.set(key, value)
|
||||
# Set special hooks
|
||||
elif key in special_hooks:
|
||||
value = getattr(gunicorn_config, key)
|
||||
if callable(value):
|
||||
self.cfg.set(key, value)
|
||||
|
||||
if hasattr(gunicorn_config, "logconfig_dict"):
|
||||
self.cfg.set(
|
||||
"logconfig_dict", getattr(gunicorn_config, "logconfig_dict")
|
||||
)
|
||||
|
||||
def load(self):
|
||||
# Import the application
|
||||
from lightrag.api.lightrag_server import get_application
|
||||
|
||||
return get_application(global_args)
|
||||
|
||||
# Create the application
|
||||
app = GunicornApp("")
|
||||
|
||||
# Force workers to be an integer and greater than 1 for multi-process mode
|
||||
workers_count = global_args.workers
|
||||
if workers_count > 1:
|
||||
# Set a flag to indicate we're in the main process
|
||||
os.environ["LIGHTRAG_MAIN_PROCESS"] = "1"
|
||||
initialize_share_data(workers_count)
|
||||
else:
|
||||
initialize_share_data(1)
|
||||
|
||||
# Run the application
|
||||
print("\nStarting Gunicorn with direct Python API...")
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Helpers for validating startup runtime expectations from `.env`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
_CONTAINER_RUNTIME_TARGETS = {"compose", "docker"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeEnvironment:
|
||||
"""Describes whether the current process is running in a container runtime."""
|
||||
|
||||
in_container: bool
|
||||
in_docker: bool
|
||||
in_kubernetes: bool
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
if self.in_kubernetes:
|
||||
return "Kubernetes"
|
||||
if self.in_docker:
|
||||
return "Docker"
|
||||
return "host"
|
||||
|
||||
|
||||
def _read_cgroup_content() -> str:
|
||||
"""Best-effort read of cgroup metadata for container detection."""
|
||||
|
||||
for candidate in ("/proc/1/cgroup", "/proc/self/cgroup"):
|
||||
try:
|
||||
return Path(candidate).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def detect_runtime_environment(
|
||||
environ: dict[str, str] | None = None,
|
||||
) -> RuntimeEnvironment:
|
||||
"""Detect whether the current process is running on host, Docker, or Kubernetes."""
|
||||
|
||||
environ = environ or os.environ
|
||||
cgroup_content = _read_cgroup_content().lower()
|
||||
|
||||
in_kubernetes = bool(
|
||||
environ.get("KUBERNETES_SERVICE_HOST")
|
||||
or Path("/var/run/secrets/kubernetes.io/serviceaccount").exists()
|
||||
or "kubepods" in cgroup_content
|
||||
or "kubernetes" in cgroup_content
|
||||
)
|
||||
in_docker = bool(
|
||||
Path("/.dockerenv").exists()
|
||||
or Path("/run/.containerenv").exists()
|
||||
or any(
|
||||
marker in cgroup_content
|
||||
for marker in ("docker", "containerd", "libpod", "podman")
|
||||
)
|
||||
)
|
||||
|
||||
return RuntimeEnvironment(
|
||||
in_container=in_kubernetes or in_docker,
|
||||
in_docker=in_docker,
|
||||
in_kubernetes=in_kubernetes,
|
||||
)
|
||||
|
||||
|
||||
def load_runtime_target_from_env_file(env_path: str | Path = ".env") -> str | None:
|
||||
"""Return the raw LIGHTRAG_RUNTIME_TARGET value from the `.env` file, if present."""
|
||||
|
||||
env_values = dotenv_values(str(env_path))
|
||||
runtime_target = env_values.get("LIGHTRAG_RUNTIME_TARGET")
|
||||
if runtime_target is None:
|
||||
return None
|
||||
return runtime_target.strip()
|
||||
|
||||
|
||||
def validate_runtime_target(
|
||||
runtime_target: str | None,
|
||||
runtime_environment: RuntimeEnvironment | None = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Validate `.env` runtime target against the current runtime environment."""
|
||||
|
||||
if runtime_target is None:
|
||||
return True, None
|
||||
|
||||
normalized_target = runtime_target.strip().lower()
|
||||
runtime_environment = runtime_environment or detect_runtime_environment()
|
||||
|
||||
if normalized_target == "host":
|
||||
if runtime_environment.in_container:
|
||||
return (
|
||||
False,
|
||||
"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET=host.\n"
|
||||
"This value from .env requires the server process to run on the host, "
|
||||
f"but the current process is running inside {runtime_environment.label}.",
|
||||
)
|
||||
return True, None
|
||||
|
||||
if normalized_target in _CONTAINER_RUNTIME_TARGETS:
|
||||
if runtime_environment.in_container:
|
||||
return True, None
|
||||
return (
|
||||
False,
|
||||
f"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET={runtime_target}.\n"
|
||||
"This value from .env requires the server process to run inside Docker or "
|
||||
"Kubernetes, but the current process is running on the host.",
|
||||
)
|
||||
|
||||
return (
|
||||
False,
|
||||
f"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET={runtime_target!r}.\n"
|
||||
"This value from .env must be 'host' or 'compose' (alias: 'docker').",
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_target_from_env_file(
|
||||
env_path: str | Path = ".env",
|
||||
runtime_environment: RuntimeEnvironment | None = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Load LIGHTRAG_RUNTIME_TARGET from `.env` and validate it if declared."""
|
||||
|
||||
runtime_target = load_runtime_target_from_env_file(env_path)
|
||||
return validate_runtime_target(runtime_target, runtime_environment)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 628 B |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
451
.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/utils_api.py
Normal file
451
.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/utils_api.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Utility functions for the LightRAG API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import argparse
|
||||
from typing import Optional, List, Tuple
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from ascii_colors import ASCIIColors
|
||||
from .._version import __api_version__ as api_version
|
||||
from .._version import __version__ as core_version
|
||||
from lightrag.constants import (
|
||||
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
|
||||
)
|
||||
from lightrag.api.runtime_validation import validate_runtime_target_from_env_file
|
||||
from fastapi import HTTPException, Security, Request, Response, status
|
||||
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
|
||||
from starlette.status import HTTP_403_FORBIDDEN
|
||||
from .auth import auth_handler
|
||||
from .config import ollama_server_infos, global_args, get_env_value
|
||||
|
||||
logger = logging.getLogger("lightrag")
|
||||
|
||||
# ========== Token Renewal Rate Limiting ==========
|
||||
# Cache to track last renewal time per user (username as key)
|
||||
# Format: {username: last_renewal_timestamp}
|
||||
_token_renewal_cache: dict[str, float] = {}
|
||||
_RENEWAL_MIN_INTERVAL = 60 # Minimum 60 seconds between renewals for same user
|
||||
|
||||
# ========== Token Renewal Path Exclusions ==========
|
||||
# Paths that should NOT trigger token auto-renewal
|
||||
# - /health: Health check endpoint, no login required
|
||||
# - /documents/paginated: Client polls this frequently (5-30s), renewal not needed
|
||||
# - /documents/pipeline_status: Client polls this very frequently (2s), renewal not needed
|
||||
_TOKEN_RENEWAL_SKIP_PATHS = [
|
||||
"/health",
|
||||
"/documents/paginated",
|
||||
"/documents/pipeline_status",
|
||||
]
|
||||
|
||||
|
||||
def check_env_file():
|
||||
"""
|
||||
Check if .env file exists and handle user confirmation if needed.
|
||||
Returns True if should continue, False if should exit.
|
||||
"""
|
||||
env_path = ".env"
|
||||
|
||||
if not os.path.exists(env_path):
|
||||
warning_msg = "Warning: Startup directory must contain .env file for multi-instance support."
|
||||
ASCIIColors.yellow(warning_msg)
|
||||
|
||||
# Check if running in interactive terminal
|
||||
if sys.stdin.isatty():
|
||||
response = input("Do you want to continue? (yes/NO): ")
|
||||
if response.lower() != "yes":
|
||||
ASCIIColors.red("Server startup cancelled")
|
||||
return False
|
||||
return True
|
||||
|
||||
is_valid, error_message = validate_runtime_target_from_env_file(env_path)
|
||||
if not is_valid:
|
||||
for line in error_message.splitlines():
|
||||
ASCIIColors.red(line)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Get whitelist paths from global_args, only once during initialization
|
||||
whitelist_paths = global_args.whitelist_paths.split(",")
|
||||
|
||||
# Pre-compile path matching patterns
|
||||
whitelist_patterns: List[Tuple[str, bool]] = []
|
||||
for path in whitelist_paths:
|
||||
path = path.strip()
|
||||
if path:
|
||||
# If path ends with /*, match all paths with that prefix
|
||||
if path.endswith("/*"):
|
||||
prefix = path[:-2]
|
||||
whitelist_patterns.append((prefix, True)) # (prefix, is_prefix_match)
|
||||
else:
|
||||
whitelist_patterns.append((path, False)) # (exact_path, is_prefix_match)
|
||||
|
||||
# Global authentication configuration
|
||||
auth_configured = bool(auth_handler.accounts)
|
||||
|
||||
|
||||
def get_combined_auth_dependency(api_key: Optional[str] = None):
|
||||
"""
|
||||
Create a combined authentication dependency that implements authentication logic
|
||||
based on API key, OAuth2 token, and whitelist paths.
|
||||
|
||||
Args:
|
||||
api_key (Optional[str]): API key for validation
|
||||
|
||||
Returns:
|
||||
Callable: A dependency function that implements the authentication logic
|
||||
"""
|
||||
# Use global whitelist_patterns and auth_configured variables
|
||||
# whitelist_patterns and auth_configured are already initialized at module level
|
||||
|
||||
# Only calculate api_key_configured as it depends on the function parameter
|
||||
api_key_configured = bool(api_key)
|
||||
|
||||
# Create security dependencies with proper descriptions for Swagger UI
|
||||
oauth2_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl="login", auto_error=False, description="OAuth2 Password Authentication"
|
||||
)
|
||||
|
||||
# If API key is configured, create an API key header security
|
||||
api_key_header = None
|
||||
if api_key_configured:
|
||||
api_key_header = APIKeyHeader(
|
||||
name="X-API-Key", auto_error=False, description="API Key Authentication"
|
||||
)
|
||||
|
||||
async def combined_dependency(
|
||||
request: Request,
|
||||
response: Response, # Added: needed to return new token via response header
|
||||
token: str = Security(oauth2_scheme),
|
||||
api_key_header_value: Optional[str] = None
|
||||
if api_key_header is None
|
||||
else Security(api_key_header),
|
||||
):
|
||||
# 1. Check if path is in whitelist
|
||||
path = request.url.path
|
||||
for pattern, is_prefix in whitelist_patterns:
|
||||
if (is_prefix and path.startswith(pattern)) or (
|
||||
not is_prefix and path == pattern
|
||||
):
|
||||
return # Whitelist path, allow access
|
||||
|
||||
# 2. Validate token first if provided in the request (Ensure 401 error if token is invalid)
|
||||
if token:
|
||||
try:
|
||||
token_info = auth_handler.validate_token(token)
|
||||
|
||||
# ========== Token Auto-Renewal Logic ==========
|
||||
from lightrag.api.config import global_args
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if global_args.token_auto_renew:
|
||||
# Check if current path should skip token renewal
|
||||
skip_renewal = any(
|
||||
path == skip_path or path.startswith(skip_path + "/")
|
||||
for skip_path in _TOKEN_RENEWAL_SKIP_PATHS
|
||||
)
|
||||
|
||||
if skip_renewal:
|
||||
logger.debug(f"Token auto-renewal skipped for path: {path}")
|
||||
else:
|
||||
try:
|
||||
expire_time = token_info.get("exp")
|
||||
if expire_time:
|
||||
# Calculate remaining time ratio
|
||||
now = datetime.now(timezone.utc)
|
||||
remaining_seconds = (expire_time - now).total_seconds()
|
||||
|
||||
# Get original token expiration duration
|
||||
role = token_info.get("role", "user")
|
||||
total_hours = (
|
||||
auth_handler.guest_expire_hours
|
||||
if role == "guest"
|
||||
else auth_handler.expire_hours
|
||||
)
|
||||
total_seconds = total_hours * 3600
|
||||
|
||||
# Issue new token if remaining time < threshold
|
||||
if (
|
||||
remaining_seconds
|
||||
< total_seconds * global_args.token_renew_threshold
|
||||
):
|
||||
# ========== Rate Limiting Check ==========
|
||||
username = token_info["username"]
|
||||
current_time = time.time()
|
||||
last_renewal = _token_renewal_cache.get(username, 0)
|
||||
time_since_last_renewal = (
|
||||
current_time - last_renewal
|
||||
)
|
||||
|
||||
# Only renew if enough time has passed since last renewal
|
||||
if time_since_last_renewal >= _RENEWAL_MIN_INTERVAL:
|
||||
new_token = auth_handler.create_token(
|
||||
username=username,
|
||||
role=role,
|
||||
metadata=token_info.get("metadata", {}),
|
||||
)
|
||||
# Return new token via response header
|
||||
response.headers["X-New-Token"] = new_token
|
||||
|
||||
# Update renewal cache
|
||||
_token_renewal_cache[username] = current_time
|
||||
|
||||
# Optional: log renewal
|
||||
logger.info(
|
||||
f"Token auto-renewed for user {username} "
|
||||
f"(role: {role}, remaining: {remaining_seconds:.0f}s)"
|
||||
)
|
||||
else:
|
||||
# Log skip due to rate limit
|
||||
logger.debug(
|
||||
f"Token renewal skipped for {username} "
|
||||
f"(rate limit: last renewal {time_since_last_renewal:.0f}s ago)"
|
||||
)
|
||||
# ========== End of Rate Limiting Check ==========
|
||||
except Exception as e:
|
||||
# Renewal failure should not affect normal request, just log
|
||||
logger.warning(f"Token auto-renew failed: {e}")
|
||||
# ========== End of Token Auto-Renewal Logic ==========
|
||||
|
||||
# Accept guest token if no auth is configured
|
||||
if not auth_configured and token_info.get("role") == "guest":
|
||||
return
|
||||
# Accept non-guest token if auth is configured
|
||||
if auth_configured and token_info.get("role") != "guest":
|
||||
return
|
||||
|
||||
# Token validation failed, immediately return 401 error
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token. Please login again.",
|
||||
)
|
||||
except HTTPException as e:
|
||||
# If already a 401 error, re-raise it
|
||||
if e.status_code == status.HTTP_401_UNAUTHORIZED:
|
||||
raise
|
||||
# For other exceptions, continue processing
|
||||
|
||||
# 3. Acept all request if no API protection needed
|
||||
if not auth_configured and not api_key_configured:
|
||||
return
|
||||
|
||||
# 4. Validate API key if provided and API-Key authentication is configured
|
||||
if (
|
||||
api_key_configured
|
||||
and api_key_header_value
|
||||
and api_key_header_value == api_key
|
||||
):
|
||||
return # API key validation successful
|
||||
|
||||
### Authentication failed ####
|
||||
|
||||
# if password authentication is configured but not provided, ensure 401 error if auth_configured
|
||||
if auth_configured and not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="No credentials provided. Please login.",
|
||||
)
|
||||
|
||||
# if api key is provided but validation failed
|
||||
if api_key_header_value:
|
||||
raise HTTPException(
|
||||
status_code=HTTP_403_FORBIDDEN,
|
||||
detail="Invalid API Key",
|
||||
)
|
||||
|
||||
# if api_key_configured but not provided
|
||||
if api_key_configured and not api_key_header_value:
|
||||
raise HTTPException(
|
||||
status_code=HTTP_403_FORBIDDEN,
|
||||
detail="API Key required",
|
||||
)
|
||||
|
||||
# Otherwise: refuse access and return 403 error
|
||||
raise HTTPException(
|
||||
status_code=HTTP_403_FORBIDDEN,
|
||||
detail="API Key required or login authentication required.",
|
||||
)
|
||||
|
||||
return combined_dependency
|
||||
|
||||
|
||||
def display_splash_screen(args: argparse.Namespace) -> None:
|
||||
"""
|
||||
Display a colorful splash screen showing LightRAG server configuration
|
||||
|
||||
Args:
|
||||
args: Parsed command line arguments
|
||||
"""
|
||||
# Banner
|
||||
# Banner
|
||||
top_border = "╔══════════════════════════════════════════════════════════════╗"
|
||||
bottom_border = "╚══════════════════════════════════════════════════════════════╝"
|
||||
width = len(top_border) - 4 # width inside the borders
|
||||
|
||||
line1_text = f"LightRAG Server v{core_version}/{api_version}"
|
||||
line2_text = "Fast, Lightweight RAG Server Implementation"
|
||||
|
||||
line1 = f"║ {line1_text.center(width)} ║"
|
||||
line2 = f"║ {line2_text.center(width)} ║"
|
||||
|
||||
banner = f"""
|
||||
{top_border}
|
||||
{line1}
|
||||
{line2}
|
||||
{bottom_border}
|
||||
"""
|
||||
ASCIIColors.cyan(banner)
|
||||
|
||||
# Server Configuration
|
||||
ASCIIColors.magenta("\n📡 Server Configuration:")
|
||||
ASCIIColors.white(" ├─ Host: ", end="")
|
||||
ASCIIColors.yellow(f"{args.host}")
|
||||
ASCIIColors.white(" ├─ Port: ", end="")
|
||||
ASCIIColors.yellow(f"{args.port}")
|
||||
ASCIIColors.white(" ├─ Workers: ", end="")
|
||||
ASCIIColors.yellow(f"{args.workers}")
|
||||
ASCIIColors.white(" ├─ Timeout: ", end="")
|
||||
ASCIIColors.yellow(f"{args.timeout}")
|
||||
ASCIIColors.white(" ├─ CORS Origins: ", end="")
|
||||
ASCIIColors.yellow(f"{args.cors_origins}")
|
||||
ASCIIColors.white(" ├─ SSL Enabled: ", end="")
|
||||
ASCIIColors.yellow(f"{args.ssl}")
|
||||
if args.ssl:
|
||||
ASCIIColors.white(" ├─ SSL Cert: ", end="")
|
||||
ASCIIColors.yellow(f"{args.ssl_certfile}")
|
||||
ASCIIColors.white(" ├─ SSL Key: ", end="")
|
||||
ASCIIColors.yellow(f"{args.ssl_keyfile}")
|
||||
ASCIIColors.white(" ├─ Ollama Emulating Model: ", end="")
|
||||
ASCIIColors.yellow(f"{ollama_server_infos.LIGHTRAG_MODEL}")
|
||||
ASCIIColors.white(" ├─ Log Level: ", end="")
|
||||
ASCIIColors.yellow(f"{args.log_level}")
|
||||
ASCIIColors.white(" ├─ Verbose Debug: ", end="")
|
||||
ASCIIColors.yellow(f"{args.verbose}")
|
||||
ASCIIColors.white(" ├─ API Key: ", end="")
|
||||
ASCIIColors.yellow("Set" if args.key else "Not Set")
|
||||
ASCIIColors.white(" └─ JWT Auth: ", end="")
|
||||
ASCIIColors.yellow("Enabled" if args.auth_accounts else "Disabled")
|
||||
|
||||
# Directory Configuration
|
||||
ASCIIColors.magenta("\n📂 Directory Configuration:")
|
||||
ASCIIColors.white(" ├─ Working Directory: ", end="")
|
||||
ASCIIColors.yellow(f"{args.working_dir}")
|
||||
ASCIIColors.white(" └─ Input Directory: ", end="")
|
||||
ASCIIColors.yellow(f"{args.input_dir}")
|
||||
|
||||
# LLM Configuration
|
||||
ASCIIColors.magenta("\n🤖 LLM Configuration:")
|
||||
ASCIIColors.white(" ├─ Binding: ", end="")
|
||||
ASCIIColors.yellow(f"{args.llm_binding}")
|
||||
ASCIIColors.white(" ├─ Host: ", end="")
|
||||
ASCIIColors.yellow(f"{args.llm_binding_host}")
|
||||
ASCIIColors.white(" ├─ Model: ", end="")
|
||||
ASCIIColors.yellow(f"{args.llm_model}")
|
||||
ASCIIColors.white(" ├─ Max Async for LLM: ", end="")
|
||||
ASCIIColors.yellow(f"{args.max_async}")
|
||||
ASCIIColors.white(" ├─ Summary Context Size: ", end="")
|
||||
ASCIIColors.yellow(f"{args.summary_context_size}")
|
||||
ASCIIColors.white(" ├─ LLM Cache Enabled: ", end="")
|
||||
ASCIIColors.yellow(f"{args.enable_llm_cache}")
|
||||
ASCIIColors.white(" └─ LLM Cache for Extraction Enabled: ", end="")
|
||||
ASCIIColors.yellow(f"{args.enable_llm_cache_for_extract}")
|
||||
|
||||
# Embedding Configuration
|
||||
ASCIIColors.magenta("\n📊 Embedding Configuration:")
|
||||
ASCIIColors.white(" ├─ Binding: ", end="")
|
||||
ASCIIColors.yellow(f"{args.embedding_binding}")
|
||||
ASCIIColors.white(" ├─ Host: ", end="")
|
||||
ASCIIColors.yellow(f"{args.embedding_binding_host}")
|
||||
ASCIIColors.white(" ├─ Model: ", end="")
|
||||
ASCIIColors.yellow(f"{args.embedding_model}")
|
||||
ASCIIColors.white(" ├─ Dimensions: ", end="")
|
||||
ASCIIColors.yellow(f"{args.embedding_dim}")
|
||||
ASCIIColors.white(" └─ Asymmetric: ", end="")
|
||||
ASCIIColors.yellow(f"{args.embedding_asymmetric}")
|
||||
|
||||
# RAG Configuration
|
||||
ASCIIColors.magenta("\n⚙️ RAG Configuration:")
|
||||
ASCIIColors.white(" ├─ Summary Language: ", end="")
|
||||
ASCIIColors.yellow(f"{args.summary_language}")
|
||||
ASCIIColors.white(" ├─ Entity Types: ", end="")
|
||||
ASCIIColors.yellow(f"{args.entity_types}")
|
||||
ASCIIColors.white(" ├─ Max Parallel Insert: ", end="")
|
||||
ASCIIColors.yellow(f"{args.max_parallel_insert}")
|
||||
ASCIIColors.white(" ├─ Chunk Size: ", end="")
|
||||
ASCIIColors.yellow(f"{args.chunk_size}")
|
||||
ASCIIColors.white(" ├─ Chunk Overlap Size: ", end="")
|
||||
ASCIIColors.yellow(f"{args.chunk_overlap_size}")
|
||||
ASCIIColors.white(" ├─ Cosine Threshold: ", end="")
|
||||
ASCIIColors.yellow(f"{args.cosine_threshold}")
|
||||
ASCIIColors.white(" ├─ Top-K: ", end="")
|
||||
ASCIIColors.yellow(f"{args.top_k}")
|
||||
ASCIIColors.white(" └─ Force LLM Summary on Merge: ", end="")
|
||||
ASCIIColors.yellow(
|
||||
f"{get_env_value('FORCE_LLM_SUMMARY_ON_MERGE', DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, int)}"
|
||||
)
|
||||
|
||||
# System Configuration
|
||||
ASCIIColors.magenta("\n💾 Storage Configuration:")
|
||||
ASCIIColors.white(" ├─ KV Storage: ", end="")
|
||||
ASCIIColors.yellow(f"{args.kv_storage}")
|
||||
ASCIIColors.white(" ├─ Vector Storage: ", end="")
|
||||
ASCIIColors.yellow(f"{args.vector_storage}")
|
||||
ASCIIColors.white(" ├─ Graph Storage: ", end="")
|
||||
ASCIIColors.yellow(f"{args.graph_storage}")
|
||||
ASCIIColors.white(" ├─ Document Status Storage: ", end="")
|
||||
ASCIIColors.yellow(f"{args.doc_status_storage}")
|
||||
ASCIIColors.white(" └─ Workspace: ", end="")
|
||||
ASCIIColors.yellow(f"{args.workspace if args.workspace else '-'}")
|
||||
|
||||
# Server Status
|
||||
ASCIIColors.green("\n✨ Server starting up...\n")
|
||||
|
||||
# Server Access Information
|
||||
protocol = "https" if args.ssl else "http"
|
||||
if args.host == "0.0.0.0":
|
||||
ASCIIColors.magenta("\n🌐 Server Access Information:")
|
||||
ASCIIColors.white(" ├─ WebUI (local): ", end="")
|
||||
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}")
|
||||
ASCIIColors.white(" ├─ Remote Access: ", end="")
|
||||
ASCIIColors.yellow(f"{protocol}://<your-ip-address>:{args.port}")
|
||||
ASCIIColors.white(" ├─ API Documentation (local): ", end="")
|
||||
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/docs")
|
||||
ASCIIColors.white(" └─ Alternative Documentation (local): ", end="")
|
||||
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/redoc")
|
||||
|
||||
ASCIIColors.magenta("\n📝 Note:")
|
||||
ASCIIColors.cyan(""" Since the server is running on 0.0.0.0:
|
||||
- Use 'localhost' or '127.0.0.1' for local access
|
||||
- Use your machine's IP address for remote access
|
||||
- To find your IP address:
|
||||
• Windows: Run 'ipconfig' in terminal
|
||||
• Linux/Mac: Run 'ifconfig' or 'ip addr' in terminal
|
||||
""")
|
||||
else:
|
||||
base_url = f"{protocol}://{args.host}:{args.port}"
|
||||
ASCIIColors.magenta("\n🌐 Server Access Information:")
|
||||
ASCIIColors.white(" ├─ WebUI (local): ", end="")
|
||||
ASCIIColors.yellow(f"{base_url}")
|
||||
ASCIIColors.white(" ├─ API Documentation: ", end="")
|
||||
ASCIIColors.yellow(f"{base_url}/docs")
|
||||
ASCIIColors.white(" └─ Alternative Documentation: ", end="")
|
||||
ASCIIColors.yellow(f"{base_url}/redoc")
|
||||
|
||||
# Security Notice
|
||||
if args.key:
|
||||
ASCIIColors.yellow("\n⚠️ Security Notice:")
|
||||
ASCIIColors.white(""" API Key authentication is enabled.
|
||||
Make sure to include the X-API-Key header in all your requests.
|
||||
""")
|
||||
if args.auth_accounts:
|
||||
ASCIIColors.yellow("\n⚠️ Security Notice:")
|
||||
ASCIIColors.white(""" JWT authentication is enabled.
|
||||
Make sure to login before making the request, and include the 'Authorization' in the header.
|
||||
""")
|
||||
|
||||
# Ensure splash output flush to system log
|
||||
sys.stdout.flush()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
import{H as e,R as t,S as n,U as r,b as i,c as a,f as o,n as s,r as c,u as l,v as u}from"./_createAssigner-CdflnHPZ.js";import{A as d,C as f,D as p,M as m,N as h,S as g,T as _,f as v,h as y,u as b,w as x}from"./_baseUniq-CSNgIvS9.js";var S=/\s/;function C(e){for(var t=e.length;t--&&S.test(e.charAt(t)););return t}var w=/^\s+/;function T(e){return e&&e.slice(0,C(e)+1).replace(w,``)}var E=NaN,D=/^[-+]0x[0-9a-f]+$/i,O=/^0b[01]+$/i,k=/^0o[0-7]+$/i,A=parseInt;function j(t){if(typeof t==`number`)return t;if(h(t))return E;if(e(t)){var n=typeof t.valueOf==`function`?t.valueOf():t;t=e(n)?n+``:n}if(typeof t!=`string`)return t===0?t:+t;t=T(t);var r=O.test(t);return r||k.test(t)?A(t.slice(2),r?2:8):D.test(t)?E:+t}var M=1/0,N=17976931348623157e292;function P(e){return e?(e=j(e),e===M||e===-M?(e<0?-1:1)*N:e===e?e:0):e===0?e:0}function F(e){var t=P(e),n=t%1;return t===t?n?t-n:t:0}function I(e){return e!=null&&e.length?g(e,1):[]}var L=Object.prototype,R=L.hasOwnProperty,z=c(function(e,n){e=Object(e);var r=-1,i=n.length,o=i>2?n[2]:void 0;for(o&&s(n[0],n[1],o)&&(i=1);++r<i;)for(var c=n[r],l=a(c),u=-1,d=l.length;++u<d;){var f=l[u],p=e[f];(p===void 0||t(p,L[f])&&!R.call(e,f))&&(e[f]=c[f])}return e});function B(e){var t=e==null?0:e.length;return t?e[t-1]:void 0}function V(e){return function(t,n,r){var i=Object(t);if(!u(t)){var a=v(n,3);t=p(t),n=function(e){return a(i[e],e,i)}}var o=e(t,n,r);return o>-1?i[a?t[o]:o]:void 0}}var H=Math.max;function U(e,t,n){var r=e==null?0:e.length;if(!r)return-1;var i=n==null?0:F(n);return i<0&&(i=H(r+i,0)),d(e,v(t,3),i)}var W=V(U);function G(e,t){var n=-1,r=u(e)?Array(e.length):[];return b(e,function(e,i,a){r[++n]=t(e,i,a)}),r}function K(e,t){return(i(e)?m:G)(e,v(t,3))}var q=Object.prototype.hasOwnProperty;function J(e,t){return e!=null&&q.call(e,t)}function Y(e,t){return e!=null&&y(e,t,J)}var X=`[object String]`;function Z(e){return typeof e==`string`||!i(e)&&n(e)&&r(e)==X}function Q(t,n,r,i){if(!e(t))return t;n=_(n,t);for(var a=-1,s=n.length,c=s-1,u=t;u!=null&&++a<s;){var d=x(n[a]),f=r;if(d===`__proto__`||d===`constructor`||d===`prototype`)return t;if(a!=c){var p=u[d];f=i?i(p,d,u):void 0,f===void 0&&(f=e(p)?p:l(n[a+1])?[]:{})}o(u,d,f),u=u[d]}return t}function $(e,t,n){for(var r=-1,i=t.length,a={};++r<i;){var o=t[r],s=f(e,o);n(s,o)&&Q(a,_(o,e),s)}return a}export{G as a,z as c,P as d,K as i,I as l,Z as n,W as o,Y as r,B as s,$ as t,F as u};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{n as e,t}from"./path-BmDdnQs8.js";import{$ as n,Q as r,X as i,Y as a,Z as o,at as s,it as c,nt as l,ot as u,rt as d,st as f,tt as p}from"./index-Cmfh6eB3.js";function m(e){return e.innerRadius}function h(e){return e.outerRadius}function g(e){return e.startAngle}function _(e){return e.endAngle}function v(e){return e&&e.padAngle}function y(e,t,n,r,i,a,o,s){var c=n-e,l=r-t,u=o-i,d=s-a,f=d*c-u*l;if(!(f*f<1e-12))return f=(u*(t-a)-d*(e-i))/f,[e+f*c,t+f*l]}function b(e,t,n,r,i,a,o){var s=e-n,c=t-r,d=(o?a:-a)/u(s*s+c*c),f=d*c,p=-d*s,m=e+f,h=t+p,g=n+f,_=r+p,v=(m+g)/2,y=(h+_)/2,b=g-m,x=_-h,S=b*b+x*x,C=i-a,w=m*_-g*h,T=(x<0?-1:1)*u(l(0,C*C*S-w*w)),E=(w*x-b*T)/S,D=(-w*b-x*T)/S,O=(w*x+b*T)/S,k=(-w*b+x*T)/S,A=E-v,j=D-y,M=O-v,N=k-y;return A*A+j*j>M*M+N*N&&(E=O,D=k),{cx:E,cy:D,x01:-f,y01:-p,x11:E*(i/C-1),y11:D*(i/C-1)}}function x(){var l=m,x=h,S=e(0),C=null,w=g,T=_,E=v,D=null,O=t(k);function k(){var e,t,m=+l.apply(this,arguments),h=+x.apply(this,arguments),g=w.apply(this,arguments)-p,_=T.apply(this,arguments)-p,v=a(_-g),k=_>g;if(D||=e=O(),h<m&&(t=h,h=m,m=t),!(h>1e-12))D.moveTo(0,0);else if(v>f-1e-12)D.moveTo(h*n(g),h*s(g)),D.arc(0,0,h,g,_,!k),m>1e-12&&(D.moveTo(m*n(_),m*s(_)),D.arc(0,0,m,_,g,k));else{var A=g,j=_,M=g,N=_,P=v,F=v,I=E.apply(this,arguments)/2,L=I>1e-12&&(C?+C.apply(this,arguments):u(m*m+h*h)),R=d(a(h-m)/2,+S.apply(this,arguments)),z=R,B=R,V,H;if(L>1e-12){var U=o(L/m*s(I)),W=o(L/h*s(I));(P-=U*2)>1e-12?(U*=k?1:-1,M+=U,N-=U):(P=0,M=N=(g+_)/2),(F-=W*2)>1e-12?(W*=k?1:-1,A+=W,j-=W):(F=0,A=j=(g+_)/2)}var G=h*n(A),K=h*s(A),q=m*n(N),J=m*s(N);if(R>1e-12){var Y=h*n(j),X=h*s(j),Z=m*n(M),Q=m*s(M),$;if(v<c)if($=y(G,K,Z,Q,Y,X,q,J)){var ee=G-$[0],te=K-$[1],ne=Y-$[0],re=X-$[1],ie=1/s(i((ee*ne+te*re)/(u(ee*ee+te*te)*u(ne*ne+re*re)))/2),ae=u($[0]*$[0]+$[1]*$[1]);z=d(R,(m-ae)/(ie-1)),B=d(R,(h-ae)/(ie+1))}else z=B=0}F>1e-12?B>1e-12?(V=b(Z,Q,G,K,h,B,k),H=b(Y,X,q,J,h,B,k),D.moveTo(V.cx+V.x01,V.cy+V.y01),B<R?D.arc(V.cx,V.cy,B,r(V.y01,V.x01),r(H.y01,H.x01),!k):(D.arc(V.cx,V.cy,B,r(V.y01,V.x01),r(V.y11,V.x11),!k),D.arc(0,0,h,r(V.cy+V.y11,V.cx+V.x11),r(H.cy+H.y11,H.cx+H.x11),!k),D.arc(H.cx,H.cy,B,r(H.y11,H.x11),r(H.y01,H.x01),!k))):(D.moveTo(G,K),D.arc(0,0,h,A,j,!k)):D.moveTo(G,K),!(m>1e-12)||!(P>1e-12)?D.lineTo(q,J):z>1e-12?(V=b(q,J,Y,X,m,-z,k),H=b(G,K,Z,Q,m,-z,k),D.lineTo(V.cx+V.x01,V.cy+V.y01),z<R?D.arc(V.cx,V.cy,z,r(V.y01,V.x01),r(H.y01,H.x01),!k):(D.arc(V.cx,V.cy,z,r(V.y01,V.x01),r(V.y11,V.x11),!k),D.arc(0,0,m,r(V.cy+V.y11,V.cx+V.x11),r(H.cy+H.y11,H.cx+H.x11),k),D.arc(H.cx,H.cy,z,r(H.y11,H.x11),r(H.y01,H.x01),!k))):D.arc(0,0,m,N,M,k)}if(D.closePath(),e)return D=null,e+``||null}return k.centroid=function(){var e=(+l.apply(this,arguments)+ +x.apply(this,arguments))/2,t=(+w.apply(this,arguments)+ +T.apply(this,arguments))/2-c/2;return[n(t)*e,s(t)*e]},k.innerRadius=function(t){return arguments.length?(l=typeof t==`function`?t:e(+t),k):l},k.outerRadius=function(t){return arguments.length?(x=typeof t==`function`?t:e(+t),k):x},k.cornerRadius=function(t){return arguments.length?(S=typeof t==`function`?t:e(+t),k):S},k.padRadius=function(t){return arguments.length?(C=t==null?null:typeof t==`function`?t:e(+t),k):C},k.startAngle=function(t){return arguments.length?(w=typeof t==`function`?t:e(+t),k):w},k.endAngle=function(t){return arguments.length?(T=typeof t==`function`?t:e(+t),k):T},k.padAngle=function(t){return arguments.length?(E=typeof t==`function`?t:e(+t),k):E},k.context=function(e){return arguments.length?(D=e??null,k):D},k}export{x as t};
|
||||
@@ -0,0 +1 @@
|
||||
import"./chunk-K5T4RW27-Bdzw7m0z.js";import{n as e}from"./chunk-7N4EOEYR-Bu6dy4pK.js";export{e as createArchitectureServices};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
Array.prototype.slice;function e(e){return typeof e==`object`&&`length`in e?e:Array.from(e)}export{e as t};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user