gitea/routers/api/v2/ai_context.go
logikonline ee5cf4e4fd
Some checks failed
Build and Release / Lint and Test (push) Successful in 2m57s
Build and Release / Build Binaries (amd64, darwin) (push) Failing after 54s
Build and Release / Build Binaries (amd64, linux) (push) Failing after 1m26s
Build and Release / Build Binaries (amd64, windows) (push) Failing after 1m16s
Build and Release / Build Binaries (arm64, darwin) (push) Failing after 1m20s
Build and Release / Build Docker Image (push) Failing after 16s
Build and Release / Build Binaries (arm64, linux) (push) Failing after 1m14s
Build and Release / Create Release (push) Has been skipped
fix: resolve build errors in v2 API and wiki service
- Fix wiki_index.go: use WebPathToGitPath/GitPathToWebPath instead of undefined functions
- Fix wiki_index.go: use gitrepo.OpenRepository pattern instead of repo.WikiPath()
- Fix wiki.go: use markdown.Render instead of undefined RenderWiki
- Fix wiki.go: use charset.ToUTF8WithFallbackReader instead of undefined ToUTF8Reader
- Fix wiki.go: use gitrepo.CommitsCount instead of undefined wikiRepo.CommitsCount
- Fix wiki.go: handle WebPathToUserTitle returning two values
- Fix gofmt formatting issues in v2 API files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:30:43 -05:00

782 lines
22 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v2
import (
"encoding/json"
"net/http"
"path"
"sort"
"strings"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
apierrors "code.gitea.io/gitea/modules/errors"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/services/context"
)
// AIRepoSummaryRequest represents a request for AI-friendly repo summary
type AIRepoSummaryRequest struct {
Owner string `json:"owner" binding:"Required"`
Repo string `json:"repo" binding:"Required"`
Ref string `json:"ref"`
}
// AIRepoSummaryResponse contains comprehensive repo information for AI tools
type AIRepoSummaryResponse struct {
// Basic info
Owner string `json:"owner"`
Repo string `json:"repo"`
FullName string `json:"full_name"`
Description string `json:"description"`
Website string `json:"website,omitempty"`
Language string `json:"primary_language"`
// Repository stats
Stars int `json:"stars"`
Forks int `json:"forks"`
Watchers int `json:"watchers"`
OpenIssues int `json:"open_issues"`
OpenPRs int `json:"open_pull_requests"`
Size int64 `json:"size_kb"`
IsPrivate bool `json:"is_private"`
IsFork bool `json:"is_fork"`
IsArchived bool `json:"is_archived"`
IsTemplate bool `json:"is_template"`
HasWiki bool `json:"has_wiki"`
HasIssues bool `json:"has_issues"`
HasProjects bool `json:"has_projects"`
// Git info
DefaultBranch string `json:"default_branch"`
Branches []string `json:"branches"`
Tags []string `json:"recent_tags"`
LastCommit struct {
SHA string `json:"sha"`
Message string `json:"message"`
Author string `json:"author"`
Timestamp string `json:"timestamp"`
} `json:"last_commit"`
// Structure overview
Structure struct {
TopLevelDirs []string `json:"top_level_dirs"`
TopLevelFiles []string `json:"top_level_files"`
FileCount int `json:"total_file_count"`
Languages map[string]int64 `json:"languages"` // language -> bytes
HasReadme bool `json:"has_readme"`
ReadmePath string `json:"readme_path,omitempty"`
HasLicense bool `json:"has_license"`
LicensePath string `json:"license_path,omitempty"`
HasContrib bool `json:"has_contributing"`
ContribPath string `json:"contributing_path,omitempty"`
ConfigFiles []string `json:"config_files"` // package.json, go.mod, etc.
} `json:"structure"`
// Recent activity
RecentActivity struct {
CommitsLastWeek int `json:"commits_last_week"`
CommitsLastMonth int `json:"commits_last_month"`
Contributors int `json:"contributors"`
} `json:"recent_activity"`
// AI-specific hints
AIHints struct {
SuggestedEntryPoints []string `json:"suggested_entry_points"`
ProjectType string `json:"project_type"` // "library", "application", "monorepo", etc.
BuildSystem string `json:"build_system,omitempty"`
TestFramework string `json:"test_framework,omitempty"`
} `json:"ai_hints"`
}
// GetAIRepoSummary returns a comprehensive AI-friendly summary of a repository
func GetAIRepoSummary(ctx *context.APIContext) {
var req AIRepoSummaryRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIErrorWithCode(apierrors.ValidationFailed, map[string]any{
"error": err.Error(),
})
return
}
// Get repository
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, req.Owner, req.Repo)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.APIErrorWithCode(apierrors.RepoNotFound)
return
}
ctx.APIErrorWithCode(apierrors.InternalError)
return
}
// Check access
if repo.IsPrivate && !ctx.IsSigned {
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
return
}
// Load owner
if err := repo.LoadOwner(ctx); err != nil {
ctx.APIErrorWithCode(apierrors.InternalError)
return
}
// Open git repo
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
ctx.APIErrorWithCode(apierrors.InternalError)
return
}
defer gitRepo.Close()
response := AIRepoSummaryResponse{
Owner: repo.Owner.Name,
Repo: repo.Name,
FullName: repo.FullName(),
Description: repo.Description,
Website: repo.Website,
Language: repo.PrimaryLanguage.Language,
Stars: repo.NumStars,
Forks: repo.NumForks,
Watchers: repo.NumWatches,
OpenIssues: repo.NumOpenIssues,
OpenPRs: repo.NumOpenPulls,
Size: repo.Size,
IsPrivate: repo.IsPrivate,
IsFork: repo.IsFork,
IsArchived: repo.IsArchived,
IsTemplate: repo.IsTemplate,
HasWiki: repo.UnitEnabled(ctx, unit.TypeWiki),
HasIssues: repo.UnitEnabled(ctx, unit.TypeIssues),
HasProjects: repo.UnitEnabled(ctx, unit.TypeProjects),
DefaultBranch: repo.DefaultBranch,
}
// Get branches (limit to recent 20)
branchNames, _, err := gitRepo.GetBranchNames(0, 20)
if err == nil {
response.Branches = branchNames
}
// Get recent tags
tagInfos, _, err := gitRepo.GetTagInfos(1, 10)
if err == nil {
tagNames := make([]string, 0, len(tagInfos))
for _, t := range tagInfos {
tagNames = append(tagNames, t.Name)
}
response.Tags = tagNames
}
// Get last commit
ref := req.Ref
if ref == "" {
ref = repo.DefaultBranch
}
if commit, err := gitRepo.GetBranchCommit(ref); err == nil {
response.LastCommit.SHA = commit.ID.String()
response.LastCommit.Message = strings.Split(commit.CommitMessage, "\n")[0]
response.LastCommit.Author = commit.Author.Name
response.LastCommit.Timestamp = commit.Author.When.Format("2006-01-02T15:04:05Z07:00")
}
// Analyze structure
if commit, err := gitRepo.GetBranchCommit(ref); err == nil {
if tree, err := commit.SubTree(""); err == nil {
entries, _ := tree.ListEntries()
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
response.Structure.TopLevelDirs = append(response.Structure.TopLevelDirs, name)
} else {
response.Structure.TopLevelFiles = append(response.Structure.TopLevelFiles, name)
// Check for special files
lowerName := strings.ToLower(name)
if strings.HasPrefix(lowerName, "readme") {
response.Structure.HasReadme = true
response.Structure.ReadmePath = name
}
if strings.HasPrefix(lowerName, "license") || lowerName == "copying" {
response.Structure.HasLicense = true
response.Structure.LicensePath = name
}
if strings.HasPrefix(lowerName, "contributing") {
response.Structure.HasContrib = true
response.Structure.ContribPath = name
}
// Check for config files
if isConfigFile(name) {
response.Structure.ConfigFiles = append(response.Structure.ConfigFiles, name)
}
}
}
}
// Count total files (approximate via tree walk, limit depth)
response.Structure.FileCount = countFiles(commit, "", 0, 5)
}
// Get language stats from repo
response.Structure.Languages = make(map[string]int64)
if langs, err := repo_model.GetLanguageStats(ctx, repo); err == nil {
for _, lang := range langs {
response.Structure.Languages[lang.Language] = int64(lang.Percentage)
}
}
// AI hints
response.AIHints = detectProjectType(response.Structure.TopLevelFiles, response.Structure.ConfigFiles, response.Language)
ctx.JSON(http.StatusOK, response)
}
// AINavigationRequest represents a request to understand repo navigation
type AINavigationRequest struct {
Owner string `json:"owner" binding:"Required"`
Repo string `json:"repo" binding:"Required"`
Ref string `json:"ref"`
Query string `json:"query"` // What the AI is looking for
}
// AINavigationResponse provides navigation hints for AI
type AINavigationResponse struct {
// Directory tree (limited depth)
Tree []TreeNode `json:"tree"`
// Important paths
ImportantPaths struct {
Entrypoints []PathInfo `json:"entrypoints"`
Config []PathInfo `json:"config"`
Tests []PathInfo `json:"tests"`
Docs []PathInfo `json:"docs"`
} `json:"important_paths"`
// File type summary
FileTypes map[string]int `json:"file_types"` // extension -> count
}
// TreeNode represents a node in the directory tree
type TreeNode struct {
Path string `json:"path"`
Name string `json:"name"`
Type string `json:"type"` // "file" or "dir"
Size int64 `json:"size,omitempty"`
Children []TreeNode `json:"children,omitempty"`
}
// PathInfo provides information about an important path
type PathInfo struct {
Path string `json:"path"`
Description string `json:"description"`
Priority int `json:"priority"` // 1-10, higher is more important
}
// GetAINavigation returns navigation hints for AI tools
func GetAINavigation(ctx *context.APIContext) {
var req AINavigationRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIErrorWithCode(apierrors.ValidationFailed, map[string]any{
"error": err.Error(),
})
return
}
// Get repository
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, req.Owner, req.Repo)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.APIErrorWithCode(apierrors.RepoNotFound)
return
}
ctx.APIErrorWithCode(apierrors.InternalError)
return
}
// Check access
if repo.IsPrivate && !ctx.IsSigned {
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
return
}
// Open git repo
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
ctx.APIErrorWithCode(apierrors.InternalError)
return
}
defer gitRepo.Close()
ref := req.Ref
if ref == "" {
ref = repo.DefaultBranch
}
response := AINavigationResponse{
FileTypes: make(map[string]int),
}
commit, err := gitRepo.GetBranchCommit(ref)
if err != nil {
ctx.APIErrorWithCode(apierrors.RefNotFound)
return
}
// Build tree (max depth 3)
response.Tree = buildTree(commit, "", 0, 3)
// Collect file types and identify important paths
collectFileInfo(commit, "", &response)
ctx.JSON(http.StatusOK, response)
}
// AIIssueContextRequest represents a request for issue context
type AIIssueContextRequest struct {
Owner string `json:"owner" binding:"Required"`
Repo string `json:"repo" binding:"Required"`
IssueNumber int64 `json:"issue_number" binding:"Required"`
}
// AIIssueContextResponse provides rich context about an issue
type AIIssueContextResponse struct {
// Issue details
Number int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Labels []string `json:"labels"`
Author string `json:"author"`
Assignees []string `json:"assignees"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// Comments
Comments []struct {
Author string `json:"author"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
} `json:"comments"`
// Related info
RelatedIssues []struct {
Number int64 `json:"number"`
Title string `json:"title"`
State string `json:"state"`
} `json:"related_issues,omitempty"`
// Code references (files mentioned in issue/comments)
CodeReferences []string `json:"code_references,omitempty"`
// AI hints
AIHints struct {
Category string `json:"category"` // "bug", "feature", "question", etc.
Complexity string `json:"complexity"` // "simple", "moderate", "complex"
SuggestedFiles []string `json:"suggested_files,omitempty"`
} `json:"ai_hints"`
}
// GetAIIssueContext returns rich context about an issue for AI tools
func GetAIIssueContext(ctx *context.APIContext) {
var req AIIssueContextRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIErrorWithCode(apierrors.ValidationFailed, map[string]any{
"error": err.Error(),
})
return
}
// Get repository
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, req.Owner, req.Repo)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.APIErrorWithCode(apierrors.RepoNotFound)
return
}
ctx.APIErrorWithCode(apierrors.InternalError)
return
}
// Check access
if repo.IsPrivate && !ctx.IsSigned {
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
return
}
// Get issue
issue, err := issues_model.GetIssueByIndex(ctx, repo.ID, req.IssueNumber)
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.APIErrorWithCode(apierrors.IssueNotFound, map[string]any{
"issue_number": req.IssueNumber,
})
return
}
ctx.APIErrorWithCode(apierrors.InternalError)
return
}
// Load related data
if err := issue.LoadPoster(ctx); err != nil {
ctx.APIErrorWithCode(apierrors.InternalError)
return
}
if err := issue.LoadLabels(ctx); err != nil {
ctx.APIErrorWithCode(apierrors.InternalError)
return
}
if err := issue.LoadAssignees(ctx); err != nil {
ctx.APIErrorWithCode(apierrors.InternalError)
return
}
response := AIIssueContextResponse{
Number: issue.Index,
Title: issue.Title,
Body: issue.Content,
State: map[bool]string{true: "closed", false: "open"}[issue.IsClosed],
Author: issue.Poster.Name,
CreatedAt: issue.CreatedUnix.AsTime().Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: issue.UpdatedUnix.AsTime().Format("2006-01-02T15:04:05Z07:00"),
}
// Labels
for _, label := range issue.Labels {
response.Labels = append(response.Labels, label.Name)
}
// Assignees
for _, assignee := range issue.Assignees {
response.Assignees = append(response.Assignees, assignee.Name)
}
// Get comments
comments, err := issues_model.FindComments(ctx, &issues_model.FindCommentsOptions{
IssueID: issue.ID,
Type: issues_model.CommentTypeComment,
})
if err == nil {
for _, comment := range comments {
if err := comment.LoadPoster(ctx); err != nil {
continue
}
response.Comments = append(response.Comments, struct {
Author string `json:"author"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
}{
Author: comment.Poster.Name,
Body: comment.Content,
CreatedAt: comment.CreatedUnix.AsTime().Format("2006-01-02T15:04:05Z07:00"),
})
}
}
// Extract code references from issue body and comments
codeRefs := extractCodeReferences(issue.Content)
for _, comment := range response.Comments {
codeRefs = append(codeRefs, extractCodeReferences(comment.Body)...)
}
response.CodeReferences = uniqueStrings(codeRefs)
// AI hints based on labels and content
response.AIHints.Category = categorizeIssue(issue.Labels, issue.Title, issue.Content)
response.AIHints.Complexity = estimateComplexity(issue.Content, len(response.Comments))
ctx.JSON(http.StatusOK, response)
}
// Helper functions
func isConfigFile(name string) bool {
configFiles := []string{
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
"go.mod", "go.sum",
"Cargo.toml", "Cargo.lock",
"requirements.txt", "setup.py", "pyproject.toml", "Pipfile",
"Gemfile", "Gemfile.lock",
"composer.json", "composer.lock",
"pom.xml", "build.gradle", "build.gradle.kts",
"CMakeLists.txt", "Makefile", "makefile",
"Dockerfile", "docker-compose.yml", "docker-compose.yaml",
".gitignore", ".gitattributes",
"tsconfig.json", "jsconfig.json",
".eslintrc", ".eslintrc.js", ".eslintrc.json",
".prettierrc", ".prettierrc.js", ".prettierrc.json",
"webpack.config.js", "vite.config.js", "rollup.config.js",
".env.example", ".env.sample",
}
for _, cf := range configFiles {
if name == cf {
return true
}
}
return false
}
func countFiles(commit *git.Commit, dir string, depth, maxDepth int) int {
if depth > maxDepth {
return 0
}
tree, err := commit.SubTree(dir)
if err != nil {
return 0
}
entries, _ := tree.ListEntries()
count := 0
for _, entry := range entries {
if entry.IsDir() {
subPath := entry.Name()
if dir != "" {
subPath = dir + "/" + entry.Name()
}
count += countFiles(commit, subPath, depth+1, maxDepth)
} else {
count++
}
}
return count
}
func detectProjectType(files, configFiles []string, primaryLang string) struct {
SuggestedEntryPoints []string `json:"suggested_entry_points"`
ProjectType string `json:"project_type"`
BuildSystem string `json:"build_system,omitempty"`
TestFramework string `json:"test_framework,omitempty"`
} {
hints := struct {
SuggestedEntryPoints []string `json:"suggested_entry_points"`
ProjectType string `json:"project_type"`
BuildSystem string `json:"build_system,omitempty"`
TestFramework string `json:"test_framework,omitempty"`
}{
ProjectType: "unknown",
}
for _, cf := range configFiles {
switch cf {
case "package.json":
hints.BuildSystem = "npm/yarn"
hints.SuggestedEntryPoints = append(hints.SuggestedEntryPoints, "package.json", "src/index.js", "src/index.ts")
case "go.mod":
hints.BuildSystem = "go modules"
hints.SuggestedEntryPoints = append(hints.SuggestedEntryPoints, "go.mod", "main.go", "cmd/")
case "Cargo.toml":
hints.BuildSystem = "cargo"
hints.SuggestedEntryPoints = append(hints.SuggestedEntryPoints, "Cargo.toml", "src/main.rs", "src/lib.rs")
case "requirements.txt", "pyproject.toml":
hints.BuildSystem = "pip/poetry"
hints.SuggestedEntryPoints = append(hints.SuggestedEntryPoints, "setup.py", "main.py", "app.py")
case "Makefile", "makefile":
hints.BuildSystem = "make"
}
}
// Detect project type
for _, f := range files {
if f == "main.go" || f == "main.rs" || f == "main.py" {
hints.ProjectType = "application"
break
}
}
if hints.ProjectType == "unknown" {
for _, cf := range configFiles {
if cf == "setup.py" || cf == "Cargo.toml" {
hints.ProjectType = "library"
break
}
}
}
return hints
}
func buildTree(commit *git.Commit, dir string, depth, maxDepth int) []TreeNode {
if depth >= maxDepth {
return nil
}
tree, err := commit.SubTree(dir)
if err != nil {
return nil
}
entries, _ := tree.ListEntries()
nodes := make([]TreeNode, 0, len(entries))
for _, entry := range entries {
node := TreeNode{
Name: entry.Name(),
Path: path.Join(dir, entry.Name()),
}
if entry.IsDir() {
node.Type = "dir"
node.Children = buildTree(commit, node.Path, depth+1, maxDepth)
} else {
node.Type = "file"
node.Size = entry.Size()
}
nodes = append(nodes, node)
}
// Sort: directories first, then alphabetically
sort.Slice(nodes, func(i, j int) bool {
if nodes[i].Type != nodes[j].Type {
return nodes[i].Type == "dir"
}
return nodes[i].Name < nodes[j].Name
})
return nodes
}
func collectFileInfo(commit *git.Commit, dir string, response *AINavigationResponse) {
tree, err := commit.SubTree(dir)
if err != nil {
return
}
entries, _ := tree.ListEntries()
for _, entry := range entries {
fullPath := path.Join(dir, entry.Name())
if entry.IsDir() {
// Check for important directories
name := strings.ToLower(entry.Name())
if name == "test" || name == "tests" || name == "__tests__" || name == "spec" {
response.ImportantPaths.Tests = append(response.ImportantPaths.Tests, PathInfo{
Path: fullPath,
Description: "Test directory",
Priority: 7,
})
}
if name == "docs" || name == "documentation" {
response.ImportantPaths.Docs = append(response.ImportantPaths.Docs, PathInfo{
Path: fullPath,
Description: "Documentation directory",
Priority: 6,
})
}
if name == "src" || name == "lib" || name == "pkg" {
response.ImportantPaths.Entrypoints = append(response.ImportantPaths.Entrypoints, PathInfo{
Path: fullPath,
Description: "Source directory",
Priority: 8,
})
}
} else {
// Count file extensions
ext := strings.ToLower(path.Ext(entry.Name()))
if ext != "" {
response.FileTypes[ext]++
}
// Check for config files
if isConfigFile(entry.Name()) {
response.ImportantPaths.Config = append(response.ImportantPaths.Config, PathInfo{
Path: fullPath,
Description: "Configuration file",
Priority: 5,
})
}
// Check for entry points
name := strings.ToLower(entry.Name())
if name == "main.go" || name == "main.rs" || name == "main.py" || name == "index.js" || name == "index.ts" {
response.ImportantPaths.Entrypoints = append(response.ImportantPaths.Entrypoints, PathInfo{
Path: fullPath,
Description: "Application entry point",
Priority: 10,
})
}
}
}
}
func extractCodeReferences(text string) []string {
// Simple extraction of file paths mentioned in text
// Look for patterns like `path/to/file.ext` or file.ext
refs := []string{}
words := strings.Fields(text)
for _, word := range words {
// Clean up markdown code blocks
word = strings.Trim(word, "`*_[]()\"'")
if strings.Contains(word, ".") && (strings.Contains(word, "/") || strings.Contains(word, "\\")) {
// Looks like a file path
if len(word) > 3 && len(word) < 200 {
refs = append(refs, word)
}
}
}
return refs
}
func uniqueStrings(input []string) []string {
seen := make(map[string]bool)
result := []string{}
for _, s := range input {
if !seen[s] {
seen[s] = true
result = append(result, s)
}
}
return result
}
func categorizeIssue(labels []*issues_model.Label, title, body string) string {
// Check labels first
for _, label := range labels {
name := strings.ToLower(label.Name)
if strings.Contains(name, "bug") {
return "bug"
}
if strings.Contains(name, "feature") || strings.Contains(name, "enhancement") {
return "feature"
}
if strings.Contains(name, "question") || strings.Contains(name, "help") {
return "question"
}
if strings.Contains(name, "documentation") || strings.Contains(name, "docs") {
return "documentation"
}
}
// Check title/body keywords
combined := strings.ToLower(title + " " + body)
if strings.Contains(combined, "error") || strings.Contains(combined, "crash") || strings.Contains(combined, "bug") {
return "bug"
}
if strings.Contains(combined, "feature") || strings.Contains(combined, "add support") || strings.Contains(combined, "would be nice") {
return "feature"
}
if strings.Contains(combined, "how to") || strings.Contains(combined, "how do i") || strings.Contains(combined, "?") {
return "question"
}
return "general"
}
func estimateComplexity(body string, commentCount int) string {
// Simple heuristics
lines := len(strings.Split(body, "\n"))
words := len(strings.Fields(body))
if lines < 10 && words < 100 && commentCount < 3 {
return "simple"
}
if lines > 50 || words > 500 || commentCount > 10 {
return "complex"
}
return "moderate"
}