[PAI-1061] fix: Handle Project Context for App Queries With New ChatIDs #5012

This commit is contained in:
sunder
2025-12-09 14:02:50 +05:30
committed by GitHub
parent 883b5d10f3
commit be2142b233
4 changed files with 331 additions and 46 deletions
+69 -5
View File
@@ -1,7 +1,8 @@
"""
Comments API tools for Plane issue comment management.
Comments API tools for Plane issue comments operations.
"""
import uuid
from typing import Any
from typing import Dict
from typing import Optional
@@ -31,7 +32,7 @@ def get_comment_tools(method_executor, context):
Args:
issue_id: Issue ID (required)
comment_html: Comment content in HTML format (required)
project_id: Project ID (optional, auto-filled from context)
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
external_source: External source identifier (e.g., "jira")
external_id: External system ID
@@ -43,6 +44,9 @@ def get_comment_tools(method_executor, context):
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Note: issue_id and project_id validation/resolution is handled centrally
# in action_execution_helpers.validate_and_resolve_ids() before tool execution
result = await method_executor.execute(
"comments",
"create",
@@ -85,7 +89,7 @@ def get_comment_tools(method_executor, context):
Args:
comment_id: Comment ID (required)
issue_id: Issue ID (required)
project_id: Project ID (optional, auto-filled from context)
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
@@ -94,6 +98,26 @@ def get_comment_tools(method_executor, context):
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
result = await method_executor.execute(
"comments",
"retrieve",
@@ -123,7 +147,7 @@ def get_comment_tools(method_executor, context):
comment_id: Comment ID (required)
issue_id: Issue ID (required)
comment_html: Comment content in HTML format
project_id: Project ID (optional, auto-filled from context)
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
external_source: External source identifier (e.g., "jira")
external_id: External system ID
@@ -134,6 +158,26 @@ def get_comment_tools(method_executor, context):
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
result = await method_executor.execute(
"comments",
"update",
@@ -162,7 +206,7 @@ def get_comment_tools(method_executor, context):
Args:
comment_id: Comment ID (required)
issue_id: Issue ID (required)
project_id: Project ID (optional, auto-filled from context)
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
@@ -171,6 +215,26 @@ def get_comment_tools(method_executor, context):
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
result = await method_executor.execute(
"comments",
"delete",
+63 -2
View File
@@ -2,6 +2,7 @@
Worklogs API tools for Plane time tracking operations.
"""
import uuid
from typing import Any
from typing import Dict
from typing import Optional
@@ -31,7 +32,7 @@ def get_worklog_tools(method_executor, context):
issue_id: Issue ID (required)
description: Worklog description (required)
duration: Duration in minutes (required)
project_id: Project ID (optional, auto-filled from context)
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
@@ -40,6 +41,26 @@ def get_worklog_tools(method_executor, context):
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
result = await method_executor.execute(
"worklogs",
"create",
@@ -67,6 +88,26 @@ def get_worklog_tools(method_executor, context):
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
result = await method_executor.execute(
"worklogs",
"list",
@@ -122,7 +163,7 @@ def get_worklog_tools(method_executor, context):
duration: Duration in minutes
created_by: User ID who created the worklog
updated_by: User ID who updated the worklog
project_id: Project ID (optional, auto-filled from context)
project_id: Project ID (optional, auto-filled from context or resolved from issue_id)
workspace_slug: Workspace slug (optional, auto-filled from context)
"""
# Auto-fill from context if not provided
@@ -131,6 +172,26 @@ def get_worklog_tools(method_executor, context):
if project_id is None and "project_id" in context:
project_id = context["project_id"]
# Programmatically resolve project_id from issue_id if missing or invalid (non-UUID)
should_resolve = False
if issue_id and not project_id:
should_resolve = True
elif issue_id and project_id:
try:
uuid.UUID(str(project_id))
except (ValueError, AttributeError):
should_resolve = True
if should_resolve:
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(issue_id))
if issue_info and issue_info.get("project_id"):
project_id = issue_info["project_id"]
except Exception:
pass
# Build update data
update_data: Dict[str, Any] = {}
if description is not None:
@@ -150,6 +150,195 @@ ENTITY_ID_FIELD_MAP = {
}
def extract_tool_params_from_artifact_data(artifact_data: Dict[str, Any], entity_type: str, action: str) -> Dict[str, Any]:
"""
Extract tool-compatible parameters from artifact data for execution.
This is the REVERSE of prepare_edited_workitem_artifact_data:
- prepare_edited: enriches simple IDs → full objects (for display)
- extract_tool_params: extracts/renames fields (for execution)
Args:
artifact_data: Raw data from frontend (uses artifact schema field names)
entity_type: Type of entity (workitem, epic, project, etc.)
action: Action type (create, update, delete)
Returns:
Tool-compatible parameters dictionary with renamed fields and unsupported fields removed
"""
# Field mapping: artifact schema → tool parameter names
ARTIFACT_TO_TOOL_MAPPING = {
"assignee_ids": "assignees",
"label_ids": "labels",
"parent_id": "parent",
"state_id": "state", # SDK adapter handles state_id internally
"lead_id": "lead", # Module lead
"member_ids": "members", # Module members
}
# Fields that cannot be set during creation (require separate API calls after creation)
CREATE_UNSUPPORTED_FIELDS = {
"workitem": {"cycle_id", "module_ids"},
"epic": {"cycle_id", "module_ids"},
}
tool_params = {}
unsupported = CREATE_UNSUPPORTED_FIELDS.get(entity_type, set()) if action == "create" else set()
# Handle nested properties structure if present
data_to_process = artifact_data.copy()
if "properties" in data_to_process and isinstance(data_to_process["properties"], dict):
# Merge properties into top level for processing
properties = data_to_process.pop("properties")
data_to_process.update(properties)
for key, value in data_to_process.items():
# Skip None values
if value is None:
continue
# Skip empty lists (but allow empty strings for explicit clearing)
if isinstance(value, list) and not value:
continue
# Skip empty description_html - API rejects empty string, use None instead
if key == "description_html" and value == "":
continue
# Skip metadata fields that shouldn't be sent to tools
if key in {"entity_info", "artifact_sub_type"}:
continue
# Filter unsupported fields for this entity type and action
if key in unsupported:
log.warning(f"Field '{key}' cannot be set during {entity_type} {action}, skipping")
continue
# Map artifact field names to tool parameter names
mapped_key = ARTIFACT_TO_TOOL_MAPPING.get(key, key)
tool_params[mapped_key] = value
log.debug(f"Extracted tool params for {entity_type} {action}: {list(tool_params.keys())}")
return tool_params
# Implicit dependency rules for action execution
# Format: (prerequisite_tool, dependent_tool)
# When both tools are present in planned actions, dependent must wait for prerequisite
IMPLICIT_DEPENDENCY_RULES = [
# Project updates must complete before creating project-scoped features
# (modules, cycles, pages, worklogs, views, intake all require project feature flags)
("projects_update", "modules_create"),
("projects_update", "cycles_create"),
("projects_update", "worklogs_create"),
("projects_update", "pages_create"),
("projects_update", "views_create"),
("projects_update", "intake_create"),
# Entity creation must complete before adding items to it
("modules_create", "modules_add_work_items"),
("cycles_create", "cycles_add_work_items"),
]
# Entity type to ID field mapping for update operations
# Maps entity types to the parameter name used for their ID in update tools
ENTITY_ID_FIELD_MAP = {
"workitem": "issue_id",
"epic": "issue_id",
"cycle": "cycle_id",
"module": "module_id",
"project": "project_id",
"label": "label_id",
"state": "state_id",
"comment": "comment_id",
"attachment": "attachment_id",
"type": "type_id",
"property": "property_id",
"intake": "intake_id",
"worklog": "worklog_id",
"link": "link_id",
}
async def validate_and_resolve_ids(tool_args: Dict[str, Any], workspace_slug: Optional[str] = None) -> Dict[str, Any]:
"""
Validate and resolve issue_id and project_id parameters before tool execution.
Handles two cases:
1. issue_id as work item identifier (e.g., 'CARB-1') instead of UUID
2. project_id as non-UUID value (e.g., project identifier, URL, etc.)
Args:
tool_args: Tool arguments from planned action
workspace_slug: Workspace context for lookups
Returns:
Updated tool_args with resolved UUIDs
"""
import uuid as uuid_module
resolved_args = tool_args.copy()
issue_id = resolved_args.get("issue_id")
project_id = resolved_args.get("project_id")
# Step 1: Validate and resolve issue_id if it's a work item identifier (e.g., CARB-1)
if issue_id:
try:
uuid_module.UUID(str(issue_id))
# issue_id is already a valid UUID, no need to resolve
except (ValueError, AttributeError):
# issue_id is not a UUID, might be an identifier like CARB-1
# Try to resolve it using search_workitem_by_identifier
try:
from pi.app.api.v1.helpers.plane_sql_queries import search_workitem_by_identifier
workitem_info = await search_workitem_by_identifier(str(issue_id), workspace_slug)
if workitem_info and workitem_info.get("id"):
log.info(f"Resolved issue_id identifier '{issue_id}' to UUID: {workitem_info["id"]}")
resolved_args["issue_id"] = str(workitem_info["id"]) # Convert to string
# Also resolve project_id from workitem if not already set
if not project_id and workitem_info.get("project_id"):
resolved_args["project_id"] = str(workitem_info["project_id"]) # Convert to string
log.info(f"Auto-resolved project_id from workitem: {workitem_info["project_id"]}")
except Exception as e:
log.warning(f"Failed to resolve issue_id '{issue_id}' from identifier: {e}")
# Step 2: Validate and resolve project_id if it's not a valid UUID
# Only resolve if we have an issue_id to resolve from
if issue_id and project_id:
try:
uuid_module.UUID(str(project_id))
# project_id is a valid UUID, no need to resolve
except (ValueError, AttributeError):
# project_id is not a UUID (might be URL, identifier, etc.)
# Resolve from issue_id
if resolved_args.get("issue_id"): # Use potentially resolved issue_id
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(resolved_args["issue_id"]))
if issue_info and issue_info.get("project_id"):
log.info(f"Resolved invalid project_id '{project_id}' to UUID: {issue_info["project_id"]}")
resolved_args["project_id"] = str(issue_info["project_id"]) # Convert to string
except Exception as e:
log.warning(f"Failed to resolve project_id from issue_id: {e}")
elif issue_id and not project_id:
# project_id missing entirely, try to resolve from issue_id
if resolved_args.get("issue_id"):
try:
from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact
issue_info = await get_issue_identifier_for_artifact(str(resolved_args["issue_id"]))
if issue_info and issue_info.get("project_id"):
log.info(f"Resolved missing project_id from issue_id: {issue_info["project_id"]}")
resolved_args["project_id"] = str(issue_info["project_id"]) # Convert to string
except Exception as e:
log.warning(f"Failed to resolve missing project_id from issue_id: {e}")
return resolved_args
def extract_tool_params_from_artifact_data(artifact_data: Dict[str, Any], entity_type: str, action: str) -> Dict[str, Any]:
"""
Extract tool-compatible parameters from artifact data for execution.
@@ -229,6 +418,9 @@ async def load_artifacts(request_data: List[ArtifactData], db: AsyncSession) ->
original_query = artifacts[0].data.get("planning_context", {}).get("original_query", "")
conversation_context = artifacts[0].data.get("planning_context", {}).get("conversation_context", {})
# Extract workspace_slug from conversation_context for ID resolution
workspace_slug = conversation_context.get("workspace_slug")
planned_actions = []
for artifact, req_item in zip(artifacts, request_data):
entity_type = artifact.data.get("planning_data", {}).get("artifact_type", "")
@@ -258,6 +450,10 @@ async def load_artifacts(request_data: List[ArtifactData], db: AsyncSession) ->
# Use original planned tool arguments
tool_args = artifact.data.get("tool_args_raw", {})
# CENTRALIZED ID VALIDATION AND RESOLUTION
# Resolve issue_id (if identifier like CARB-1) and project_id (if invalid UUID) before execution
tool_args = await validate_and_resolve_ids(tool_args, workspace_slug)
planned_actions.append({
"artifact_id": str(artifact.id),
"tool_name": artifact.data.get("planning_data", {}).get("tool_name", ""),
@@ -13,7 +13,7 @@ import requests
# Configuration
BASE_URL = "http://localhost:8001" # Update with your actual base URL
BASE_PATH = "" # Update if your API has a base path (e.g., "/pi")
AUTHORIZATION_TOKEN = "plane_api_c9a0aae9011c483bae231b655afac38d" # Your Plane access token or API key
AUTHORIZATION_TOKEN = "plane_api_28a5ceb716424a47b358881d9d185e1f" # Your Plane access token or API key
# AUTHORIZATION_TOKEN = "plane_api_c9a0aae9011c483bae231b655afac38d" # Your Plane access token or API key
# Endpoint URL
@@ -46,45 +46,9 @@ project_id = "47ed2e28-3e91-4ba8-a463-4a117941e2d4"
context = {"first_name": "sunder", "last_name": "Chintada"}
query = """
This was the previous conversation history: \n\n
===================\n\n
User: give me one issue assigned to me in PALL project
Assistant: There are currently no work-items assigned to you in the PALL project. If you need help finding work-items in another project or have a different request, just let me know!
Assistant: There are currently no work-items assigned to you in the PALL project. If you need help finding work-items in another project or want to adjust your search, just let me know!
User: create a task in PALL project title - "Hello task" and assign it to me
Assistant: Successfully created work item 'Hello task' - <http://127.0.0.1:3000/integrations03/browse/PALL-4/>
User: do I have any task assigned to me in PALL project. If yes, show it
Assistant: Yes, you currently have one task assigned to you in the PALL project:
• *Task Name:* Hello task
• *Unique Key:* Not provided in the data
• *Priority:* None
• *State:* Backlog
If you need more details or want to take action on this task, just let me know!
User: what project are we discussing in this thread
Assistant: It seems like you're referring to a previous conversation about the PALL project. If you have any specific questions or need further information about the PALL project, feel free to ask!
User: create a task with title Test World
Assistant: Successfully created work item 'Test World' - <http://127.0.0.1:3000/integrations03/browse/PALL-5/>
User: what are we discussing?
Assistant: I'm sorry, but I don't have access to past conversation history. However, I can help with any questions or information you need right now. Feel free to ask!
User: try again
Assistant: I'm here to help with any questions or tasks you have. However, I don't have access to past conversation history. If you have any specific questions or need assistance, feel free to let me know!
User: past conversation is available under `This was the previous conversation history` what are we discussing
Assistant: I'm sorry, but I don't have access to the previous conversation history. However, if you provide me with some details or context about the project and work items, I'd be happy to help you summarize or discuss them further!
User: summarize this thread
Assistant: I'm sorry, but I can't provide a summary of the conversation history as there is no prior conversation content available. If you have specific questions or need assistance with a project, feel free to let me know!
===================\n\n
πCurrent user messageπ:\
What are we discussing in this thread?
Here is the conversation history for context: =================== User: can you talk to SSI folks once. also anything from EOP? User: any update on this for SSI? User: CDN one resolved, we are fixing all external call related issues in Air-gapped 2.0.0 commercial release. User: do let us know if you hear anything from SSI, . User: Can we check with them on which version they are on? User: I guess they are still running Plane v1.11.1 User: If they are on the old version. Please request them to move to latest version. User: New Airgapped License can be downloaded from the prime.plane.so User: Here was Jake @ SSIs response to me: "Thanks Jason. There are no active issues I am aware of besides the broken image links on the air-gapped version. Any word on if your team is close to a fix there?" User: create an issue on we application project and assign it to Manish heres context Here was Jake @ SSIs response to me: "Thanks Jason. There are no active issues I am aware of besides the broken image links on the air-gapped version. Any word on if your team is close to a fix there?" Assistant: Successfully created work item 'Chassis Design' - <http://127.0.0.1:3000/switchertest/browse/CARB-1/> User: created the issue youll be notified after its fixed User: Any idea which version they are on? User: I have updated the description with details. in the above ticket. User: Hmm, may know User: About? User: &gt; Any idea which version they are on? Curious if he knows the version User: Ohh okay. User: Just to set the expectation. The image thing that was mentioned by SSI will not be fixed as part of v2.0.0. Which is scheduled to release on 5th december. User: in the same issue can you add a comment saying "Send latest instructions for SSI to upgrade to v2, Sriram mentioned their issue will be fixed by tomorrow after a patch" =================== πCurrent user messageπ: in the same issue can you add a comment saying "Send latest instructions for SSI to upgrade to v2, Sriram mentioned their issue will be fixed by tomorrow after a patch"
""" # noqa E501
query = """
πCurrent user messageπ:
create a task with title Test World in Starlette project
"""
# Request payload - ChatRequest schema
payload = {
"query": query,