Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ WIKI_ENABLED=True
# The team's bot MUST have a model configured (bind_model or custom config)
# If no model is configured, wiki generation will be disabled in the frontend
WIKI_DEFAULT_TEAM_NAME=wiki-team
# Team that runs knowledge-base code wikis (matches init_data/02-public-resources.yaml)
# Separate from WIKI_DEFAULT_TEAM_NAME: the two paths give their agents different
# instructions and different submission rules, so they cannot share one team.
# Its bot MUST have a model configured (bind_model or custom config)
WIKI_CODE_WIKI_TEAM_NAME=code-wiki-team
# Default agent type for wiki generation
WIKI_DEFAULT_AGENT_TYPE=ClaudeCode
# Default language for wiki documentation generation (en = English, zh = Chinese)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""add knowledge content origin and wiki generation kb link

Revision ID: bd9c871a93d2
Revises: b9c0d1e2f3a4

Adds the two things a code wiki needs to coexist with ordinary knowledge content:

- ``origin`` on documents and folders, marking whether a row is agent-generated or
user-owned. It defaults to ``user`` so that every existing row, and every row this
service does not create itself, is excluded from the generated-content projection.
Getting this backwards would let a regeneration delete content nobody can restore.
- ``kind_id`` on wiki generations, binding a version line to a knowledge base. The
versions previously hung off ``wiki_projects``, whose ``source_url`` is globally
unique; leaving them there would share one version line between knowledge bases
tracking the same repository. ``0`` marks rows that predate code wikis.

All three columns are NOT NULL with a server default, as required for existing rows to
backfill without a nullable intermediate state.
"""

from collections.abc import Sequence
from typing import Union

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "bd9c871a93d2"
down_revision: Union[str, Sequence[str], None] = "b9c0d1e2f3a4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
Comment on lines +28 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# List all revision/down_revision declarations to detect missing parents or multiple heads.
fd -e py . backend/alembic/versions --exec rg -n '^revision(: str)? *=|^down_revision' {} \; -x true
python3 - <<'PY'
import ast, pathlib
revs, downs = {}, {}
for p in sorted(pathlib.Path("backend/alembic/versions").glob("*.py")):
    src = p.read_text()
    tree = ast.parse(src, filename=str(p))
    a = {}
    for node in tree.body:
        if isinstance(node, ast.Assign):
            for t in node.targets:
                if isinstance(t, ast.Name):
                    try:
                        a[t.id] = ast.literal_eval(node.value)
                    except Exception:
                        pass
        if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
            try:
                a[node.target.id] = ast.literal_eval(node.value)
            except Exception:
                pass
    if "revision" in a:
        revs[a["revision"]] = str(p)
        downs[a["revision"]] = a.get("down_revision")
parents = {d for d in downs.values() if d}
print("missing parents:", sorted(p for p in parents if p not in revs))
print("heads:", sorted(r for r in revs if r not in parents))
PY

Repository: wecode-ai/Wegent

Length of output: 2396


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Candidate migration files containing bd9c871a93d2 / b9c0d1e2f3a4"
rg -n "bd9c871a93d2|b9c0d1e2f3a4|revision = \"(c8d2e3f4a5b6|e6f7a8b9c013|e6f7a8b9c012|a2b3c4d5e6f7)\"" backend/alembic/versions || true

echo
echo "## Alembic env/config files"
git ls-files 'backend/alembic/**'
python3 - <<'PY'
import pathlib
for p in pathlib.Path("backend/alembic").rglob("*"):
    if p.is_file() and p.suffix in {".py", ".ini"}:
        if any(x in p.parts for x in {"versions", "env.py", "script.py.mako", "config.py"}):
            print(p)
PY

Repository: wecode-ai/Wegent

Length of output: 5842


Resolve the Alembic graph before applying this migration.

bd9c871a93d2 correctly points to b9c0d1e2f3a4, but the migration tree still has multiple heads (bd9c871a93d2, c8d2e3f4a5b6, e6f7a8b9c012, e6f7a8b9c013, and a2b3c4d5e6f7). Merge or relink the branches so upgrade head applies one linear sequence and the schema is not split across heads.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/alembic/versions/20260731_bd9c871a93d2_add_knowledge_content_origin_and_wiki_.py`
around lines 28 - 32, Resolve the Alembic revision graph around revision
bd9c871a93d2 and the other listed heads by merging or relinking the migration
branches into one upgrade path. Update the affected revision identifiers and add
any required merge revision so upgrade head resolves to a single linear sequence
while preserving the existing schema changes.

Source: Coding guidelines



def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
"knowledge_documents",
sa.Column(
"origin",
sa.String(length=20),
nullable=False,
server_default="user",
comment="Content ownership: 'generated' (agent-owned) or 'user'",
),
)
op.add_column(
"knowledge_folders",
sa.Column(
"origin",
sa.String(length=20),
nullable=False,
server_default="user",
comment="Content ownership: 'generated' (agent-owned) or 'user'",
),
)
op.add_column(
"wiki_generations",
sa.Column(
"kind_id",
sa.Integer(),
nullable=False,
server_default="0",
comment="Knowledge base this version line belongs to; 0 = legacy row",
),
)
op.create_index(
"ix_wiki_generations_kind_id", "wiki_generations", ["kind_id"], unique=False
)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_index("ix_wiki_generations_kind_id", table_name="wiki_generations")
op.drop_column("wiki_generations", "kind_id")
op.drop_column("knowledge_folders", "origin")
op.drop_column("knowledge_documents", "origin")
156 changes: 156 additions & 0 deletions backend/app/api/endpoints/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@
AllGroupedKnowledgeResponse,
BatchDocumentIds,
BatchOperationResult,
CodeWikiCreate,
CodeWikiRunCreate,
CodeWikiRunResponse,
DocumentContentUpdate,
DocumentDetailResponse,
DocumentMoveRequest,
InitialMemberCreate,
KnowledgeBaseCreate,
KnowledgeBaseListResponse,
KnowledgeBaseResponse,
KnowledgeBaseType,
KnowledgeBaseTypeUpdate,
KnowledgeBaseUpdate,
KnowledgeDocumentCreate,
Expand All @@ -64,6 +68,14 @@
KnowledgeService,
knowledge_base_qa_service,
)
from app.services.knowledge.code_wiki.generation import GenerationInFlight
from app.services.knowledge.code_wiki.run_mode import ChangedPath
from app.services.knowledge.code_wiki.runner import CodeWikiRunError, start_run
from app.services.knowledge.code_wiki.source import (
SourceAccessDenied,
SourceRepository,
assert_user_can_read_source,
)
from app.services.knowledge.orchestrator import (
DEFAULT_KNOWLEDGE_LIST_LIMIT,
MAX_DOCUMENT_READ_LIMIT,
Expand Down Expand Up @@ -424,6 +436,18 @@ def create_knowledge_base(
- **namespace=<group_name>**: Team knowledge base (requires Maintainer+ permission)
- **members**: Optional initial members to add after creation
"""
# A code wiki must be created through its own endpoint, which binds a repository
# and checks the caller can read it. Refusing here rather than quietly ignoring
# the field keeps this endpoint from becoming a way around that check.
if data.kb_type == KnowledgeBaseType.CODE_WIKI:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Use POST /knowledge-bases/code-wikis to create a "
"'code_wiki' knowledge base"
),
)

try:
# Use Orchestrator for unified business logic (REST API and MCP tools share the same logic)
result = knowledge_orchestrator.create_knowledge_base(
Expand Down Expand Up @@ -468,6 +492,138 @@ def create_knowledge_base(
)


@router.post(
"/code-wikis",
response_model=KnowledgeBaseResponse,
status_code=status.HTTP_201_CREATED,
)
@trace_sync("create_code_wiki", "knowledge.api")
def create_code_wiki(
data: CodeWikiCreate,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db),
):
"""Create a code wiki bound to a source repository.

The requester must be able to read the repository, so that a wiki cannot be built
for a private repository they have no access to. Reading the resulting wiki is
then governed by knowledge-base permissions alone: place it in an organization
namespace to make it readable by everyone signed in, or keep it in a restricted
namespace and share it explicitly.
"""
try:
source = SourceRepository.from_url(data.source_type, data.source_url)
assert_user_can_read_source(db, current_user.id, source)
except SourceAccessDenied as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) from e

try:
result = knowledge_orchestrator.create_knowledge_base(
db=db,
user=current_user,
name=data.name,
description=data.description,
namespace=data.namespace or "default",
kb_type=KnowledgeBaseType.CODE_WIKI.value,
source=source,
)
add_span_event(
"knowledge.code_wiki.created",
{
"kb_id": str(result.id),
"project_name": source.project_name,
"namespace": data.namespace or "default",
"user_id": str(current_user.id),
},
)
return result
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Knowledge base with name '{data.name}' already exists in this namespace",
) from e
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
) from e


@router.post(
"/{knowledge_base_id}/code-wiki/generations",
response_model=CodeWikiRunResponse,
status_code=status.HTTP_202_ACCEPTED,
)
@trace_sync("start_code_wiki_run", "knowledge.api")
def start_code_wiki_run(
knowledge_base_id: int,
data: CodeWikiRunCreate,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db),
):
"""Regenerate a code wiki now, without waiting for its schedule.

Managing the knowledge base is required rather than merely reading it: a run
rewrites every page in it, so this is closer to replacing its content than to
viewing it.

Answers 202 even when no run was needed. "The repository has not changed since the
published version" is a successful outcome, not a failure, and the response says
which it was.
"""
knowledge_base = KnowledgeService._get_knowledge_base_record(db, knowledge_base_id)
if knowledge_base is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Knowledge base not found"
)
if not KnowledgeService.can_manage_knowledge_base(
db, knowledge_base_id, current_user.id
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to regenerate this code wiki",
)

try:
started = start_run(
db,
knowledge_base=knowledge_base,
user=current_user,
head_commit=data.head_commit,
changed_paths=(
None
if data.changed_paths is None
else [
ChangedPath(path=item.path, status=item.change_type)
for item in data.changed_paths
]
),
)
except GenerationInFlight as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
except CodeWikiRunError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
) from e

add_span_event(
"knowledge.code_wiki.run_requested",
{
"kb_id": str(knowledge_base_id),
"started": str(started.started),
"mode": started.mode,
"user_id": str(current_user.id),
},
)
return CodeWikiRunResponse(
started=started.started,
mode=started.mode,
reason=started.reason,
generation_id=started.generation.id if started.generation else 0,
task_id=started.task_id,
)


@router.get("/{knowledge_base_id}", response_model=KnowledgeBaseResponse)
@trace_sync("get_knowledge_base", "knowledge.api")
def get_knowledge_base(
Expand Down
30 changes: 30 additions & 0 deletions backend/app/api/endpoints/wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
WikiGenerationDetail,
WikiGenerationInDB,
WikiGenerationListResponse,
WikiPageRead,
WikiProjectDetail,
WikiProjectInDB,
WikiProjectListResponse,
Expand Down Expand Up @@ -217,6 +218,35 @@ def save_wiki_generation_contents(
return None


@internal_router.get("/generations/{generation_id}/pages", response_model=WikiPageRead)
def read_wiki_generation_page(
generation_id: int,
path: str = Query(..., min_length=1, description="Stable page path to read"),
_: None = Depends(_verify_internal_token),
wiki_db: Session = Depends(get_wiki_db),
):
"""Read one page of the version the agent is writing into (internal use).

An incremental version begins as a complete copy of the published wiki, so the
agent's own generation is also the current wiki — which makes "read your own
version" both the capability it needs and the narrowest scope that provides it.
Without this the instruction to revise a page cannot be followed: the agent knows
the page's path and cannot see a word of what it says.

Answers 404 when the path holds no page. That is a useful answer rather than a
failure — in an incremental run it means the page is new.
"""
page = wiki_service.get_generation_page(
wiki_db=wiki_db, generation_id=generation_id, path=path
)
if page is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Generation {generation_id} has no page at '{path}'",
)
return page
Comment on lines +266 to +293

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

The "own generation only" scope is documented but not enforced. _verify_internal_token authorizes any active user's JWT and does not bind the caller to a generation, so read_wiki_generation_page returns page content for any generation_id. The skill documentation states a narrower scope than the endpoint implements.

  • backend/app/api/endpoints/wiki.py#L221-L247: resolve the caller from the JWT and verify the generation belongs to that caller, or require the internal token; reject other callers with 403.
  • backend/init_data/skills/wiki_submit/SKILL.md#L53-L63: after the endpoint enforces the scope, keep the wording; if the endpoint stays open to any signed-in user, remove the "Only your own generation is readable" claim.
🧰 Tools
🪛 Ruff (0.16.0)

[warning] 226-226: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

📍 Affects 2 files
  • backend/app/api/endpoints/wiki.py#L221-L247 (this comment)
  • backend/init_data/skills/wiki_submit/SKILL.md#L53-L63
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/endpoints/wiki.py` around lines 221 - 247, Enforce the
documented own-generation scope in read_wiki_generation_page by resolving the
JWT caller and verifying that generation_id belongs to that caller, or otherwise
require the internal token; return 403 for unauthorized generations. In
backend/app/api/endpoints/wiki.py lines 221-247, update the endpoint
authorization while preserving its page lookup and 404 behavior. In
backend/init_data/skills/wiki_submit/SKILL.md lines 53-63, keep the “Only your
own generation is readable” wording once the endpoint enforces this restriction;
if authorization remains open to any signed-in user, remove that claim instead.



@router.get(
"/generations/{generation_id}/contents", response_model=list[WikiContentInDB]
)
Expand Down
7 changes: 7 additions & 0 deletions backend/app/core/wiki_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ class WikiSettings(BaseSettings):
"wiki-team" # Default execution team name (matches init_data/01-default-resources.yaml)
)
DEFAULT_AGENT_TYPE: str = "ClaudeCode" # Default agent type
# Team that runs knowledge-base code wikis (env var: WIKI_CODE_WIKI_TEAM_NAME).
# Separate from DEFAULT_TEAM_NAME because the two paths hand their agents
# different instructions and different submission rules; pointing both at one team
# would give the legacy wiki the page-path write contract it cannot satisfy.
CODE_WIKI_TEAM_NAME: str = (
"code-wiki-team" # Matches init_data/02-public-resources.yaml
)
DEFAULT_USER_ID: int = 0 # Default user ID for task creation (0 = use current user)
DEFAULT_LANGUAGE: str = (
"en" # Default language for wiki documentation generation (en/zh)
Expand Down
32 changes: 32 additions & 0 deletions backend/app/models/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ class DocumentSourceType(str, PyEnum):
TEXT = "text" # Pasted text
TABLE = "table" # External table (DingTalk, Feishu, etc.)
WEB = "web" # Web page (scraped URL)
CODE = "code" # Source file indexed for retrieval, not a browsable document


class ContentOrigin(str, PyEnum):
"""Ownership of knowledge content, deciding whether regeneration may touch it.

``GENERATED`` content is owned by the generating agent: it may be overwritten by
a later run and removed by generation-set reconciliation. ``USER`` content is
owned by a person and is never touched by regeneration.

``USER`` is the safe default: mislabelling generated content as user-owned only
stops automatic cleanup, while the reverse would expose user content to deletion.
"""

GENERATED = "generated"
USER = "user"


class DocumentIndexStatus(str, PyEnum):
Expand Down Expand Up @@ -131,6 +147,14 @@ class KnowledgeDocument(Base):
source_config = Column(
JSON, nullable=False, default={}
) # Source configuration (e.g., {"url": "..."} for table)
# Content ownership; see ContentOrigin. Defaults to ``user`` so that any row this
# service did not create is protected from the generated-content projection.
origin = Column(
String(20),
nullable=False,
default=ContentOrigin.USER.value,
server_default=ContentOrigin.USER.value,
)

# --- Helper properties for converted attachment reference ---

Expand Down Expand Up @@ -210,6 +234,14 @@ class KnowledgeFolder(Base):
# Self-referencing parent folder, 0 = root level
parent_id = Column(Integer, nullable=False, default=0)
name = Column(String(255), nullable=False)
# Content ownership; a top-level folder decides ownership for its subtree.
# Defaults to ``user`` for the same fail-safe reason as on documents.
origin = Column(
String(20),
nullable=False,
default=ContentOrigin.USER.value,
server_default=ContentOrigin.USER.value,
)
created_at = Column(DateTime, nullable=False, default=func.now())
updated_at = Column(
DateTime, nullable=False, default=func.now(), onupdate=func.now()
Expand Down
Loading
Loading