sophhub 0.4.46 → 0.4.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/agents/ai-cs-qa/.config.json +1 -1
  2. package/agents/ai-cs-qa/AGENTS.md +24 -3
  3. package/agents/ai-cs-qa/TOOLS.md +1 -1
  4. package/package.json +1 -1
  5. package/skills/sophnet-invoice-assistant/skill.json +21 -0
  6. package/skills/sophnet-invoice-assistant/src/SKILL.md +186 -0
  7. package/skills/sophnet-invoice-assistant/src/pyproject.toml +8 -0
  8. package/skills/sophnet-invoice-assistant/src/references/api-contract.md +73 -0
  9. package/skills/sophnet-invoice-assistant/src/references/conversation-examples.md +16 -0
  10. package/skills/sophnet-invoice-assistant/src/references/error-cases.md +16 -0
  11. package/skills/sophnet-invoice-assistant/src/scripts/__init__.py +1 -0
  12. package/skills/sophnet-invoice-assistant/src/scripts/auth_context.py +74 -0
  13. package/skills/sophnet-invoice-assistant/src/scripts/cli.py +136 -0
  14. package/skills/sophnet-invoice-assistant/src/scripts/download_flow.py +89 -0
  15. package/skills/sophnet-invoice-assistant/src/scripts/dto.py +46 -0
  16. package/skills/sophnet-invoice-assistant/src/scripts/invoice_client.py +164 -0
  17. package/skills/sophnet-invoice-assistant/src/scripts/output.py +63 -0
  18. package/skills/sophnet-invoice-assistant/src/scripts/prepare_flow.py +58 -0
  19. package/skills/sophnet-invoice-assistant/src/scripts/risk_rules.py +95 -0
  20. package/skills/sophnet-invoice-assistant/src/scripts/status_flow.py +69 -0
  21. package/skills/sophnet-invoice-assistant/src/scripts/submit_flow.py +472 -0
  22. package/skills/sophnet-invoice-assistant/src/scripts/template_flow.py +196 -0
  23. package/skills/sophnet-invoice-assistant/src/scripts/update_flow.py +223 -0
@@ -0,0 +1,89 @@
1
+ """Issued invoice download skeleton."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from invoice_client import InvoiceClientError
6
+ from output import build_result, failure, need_confirmation
7
+
8
+
9
+ def _candidate_summary(items):
10
+ values = []
11
+ for item in items[:5]:
12
+ values.append(
13
+ "{0}({1})".format(
14
+ item.get("applicationNo") or "",
15
+ item.get("title") or item.get("status") or "unknown",
16
+ )
17
+ )
18
+ return "Multiple issued invoices were found. Specify an application number before downloading: {0}".format(", ".join(values))
19
+
20
+
21
+ def run_download(request, auth_context, client):
22
+ if not auth_context.is_authenticated:
23
+ return failure(
24
+ action=request.action,
25
+ error_code="AUTH_CONTEXT_MISSING",
26
+ error_message="Current Sophnet auth context is missing. Please log in again.",
27
+ )
28
+
29
+ try:
30
+ org = client.get_current_personal_org(request.org_id)
31
+ target_application_no = request.application_no
32
+ if not target_application_no:
33
+ page = client.list_invoice_applications(
34
+ org.get("id"),
35
+ params={"pageNum": 1, "pageSize": 10, "status": "ISSUED"},
36
+ )
37
+ items = page.get("list") or [] if isinstance(page, dict) else []
38
+ if not items:
39
+ return failure(
40
+ action=request.action,
41
+ error_code="NO_ISSUED_APPLICATION_FOUND",
42
+ error_message="No issued invoice is currently available for download under the personal org.",
43
+ extra={"ORG_ID": str(org.get("id"))},
44
+ )
45
+ if len(items) > 1:
46
+ return need_confirmation(
47
+ action=request.action,
48
+ summary=_candidate_summary(items),
49
+ message="Multiple downloadable invoices were found. Please specify an application number.",
50
+ extra={
51
+ "ORG_ID": str(org.get("id")),
52
+ "CANDIDATE_APPLICATION_NOS": ",".join(
53
+ [str(item.get("applicationNo") or "") for item in items[:10]]
54
+ ),
55
+ },
56
+ )
57
+ target_application_no = items[0].get("applicationNo")
58
+
59
+ if not target_application_no:
60
+ return failure(
61
+ action=request.action,
62
+ error_code="APPLICATION_NO_REQUIRED",
63
+ error_message="The invoice application number is required before downloading.",
64
+ )
65
+
66
+ file_info = client.download_invoice_file(org.get("id"), target_application_no)
67
+ except InvoiceClientError as exc:
68
+ return failure(
69
+ action=request.action,
70
+ error_code=exc.code,
71
+ error_message=exc.message,
72
+ )
73
+
74
+ return build_result(
75
+ status="succeeded",
76
+ action=request.action,
77
+ message="Invoice download details were retrieved successfully.",
78
+ extra={
79
+ "ORG_ID": str(org.get("id")),
80
+ "ORG_NAME": org.get("name") or "",
81
+ "APPLICATION_NO": file_info.get("applicationNo") or str(target_application_no),
82
+ "FILE_NAME": file_info.get("fileName") or "",
83
+ "CONTENT_TYPE": file_info.get("contentType") or "",
84
+ "FILE_SIZE": str(file_info.get("fileSize") or ""),
85
+ "PREVIEW_URL": file_info.get("previewUrl") or "",
86
+ "DOWNLOAD_URL": file_info.get("downloadUrl") or "",
87
+ "EXPIRES_IN": str(file_info.get("expiresIn") or ""),
88
+ },
89
+ )
@@ -0,0 +1,46 @@
1
+ """Data transfer objects for the invoice assistant skeleton."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Dict, Optional
7
+
8
+
9
+ @dataclass
10
+ class ActionRequest(object):
11
+ action: str
12
+ user_request: str
13
+ org_id: Optional[str] = None
14
+ application_no: Optional[str] = None
15
+ template_id: Optional[str] = None
16
+ confirm: bool = False
17
+ buyer_type: Optional[str] = None
18
+ title: Optional[str] = None
19
+ tax_no: Optional[str] = None
20
+ address_phone: Optional[str] = None
21
+ bank_account: Optional[str] = None
22
+ invoice_type: Optional[str] = None
23
+ recharge_trans_nos: Optional[str] = None
24
+ issuing_mode: Optional[str] = None
25
+ save_as_template: bool = False
26
+ template_name: Optional[str] = None
27
+ use_latest_template: bool = False
28
+
29
+
30
+ @dataclass
31
+ class AuthContext(object):
32
+ api_base_url: str
33
+ access_token: Optional[str]
34
+ cookie: Optional[str]
35
+
36
+ @property
37
+ def is_authenticated(self) -> bool:
38
+ return bool(self.access_token or self.cookie)
39
+
40
+
41
+ @dataclass
42
+ class ActionResult(object):
43
+ status: str
44
+ action: str
45
+ message: str
46
+ extra: Optional[Dict[str, str]] = None
@@ -0,0 +1,164 @@
1
+ """Sophnet invoice API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import requests
6
+
7
+
8
+ class InvoiceClientError(RuntimeError):
9
+ def __init__(self, code, message):
10
+ super(InvoiceClientError, self).__init__(message)
11
+ self.code = code
12
+ self.message = message
13
+
14
+
15
+ class InvoiceClient(object):
16
+ def __init__(self, auth_context):
17
+ self.auth_context = auth_context
18
+ self.base_url = auth_context.api_base_url.rstrip("/")
19
+ self.timeout = 30
20
+
21
+ def _headers(self):
22
+ headers = {
23
+ "Accept": "application/json",
24
+ }
25
+ if self.auth_context.access_token:
26
+ headers["Authorization"] = "Bearer {0}".format(self.auth_context.access_token.strip())
27
+ if self.auth_context.cookie:
28
+ headers["Cookie"] = self.auth_context.cookie.strip()
29
+ return headers
30
+
31
+ def _unwrap_result(self, payload):
32
+ if not isinstance(payload, dict):
33
+ return payload
34
+ status = payload.get("status")
35
+ if status not in (None, 0):
36
+ message = payload.get("message") or "Sophnet API returned an error"
37
+ raise InvoiceClientError("SOPHNET_API_ERROR", message)
38
+ return payload.get("result")
39
+
40
+ def _request(self, method, path, params=None, json_body=None):
41
+ url = "{0}{1}".format(self.base_url, path)
42
+ try:
43
+ response = requests.request(
44
+ method=method,
45
+ url=url,
46
+ headers=self._headers(),
47
+ params=params,
48
+ json=json_body,
49
+ timeout=self.timeout,
50
+ )
51
+ except requests.RequestException as exc:
52
+ raise InvoiceClientError("NETWORK_ERROR", "Failed to request Sophnet: {0}".format(exc))
53
+
54
+ if response.status_code in (401, 403):
55
+ raise InvoiceClientError("AUTH_EXPIRED", "Current Sophnet auth has expired. Please log in again.")
56
+
57
+ try:
58
+ payload = response.json()
59
+ except ValueError:
60
+ payload = None
61
+
62
+ if not response.ok:
63
+ if isinstance(payload, dict):
64
+ message = payload.get("message") or payload.get("error") or response.text
65
+ else:
66
+ message = response.text
67
+ raise InvoiceClientError(
68
+ "HTTP_{0}".format(response.status_code),
69
+ "Sophnet API request failed: {0}".format((message or "").strip()[:300]),
70
+ )
71
+
72
+ return self._unwrap_result(payload)
73
+
74
+ def list_joined_orgs(self):
75
+ result = self._request(
76
+ "GET",
77
+ "/orgs/joined-org",
78
+ params={"pageNum": 1, "pageSize": 9999},
79
+ )
80
+ if not isinstance(result, dict):
81
+ raise InvoiceClientError("UNEXPECTED_RESPONSE", "joined-org returned an unexpected response shape.")
82
+ return result
83
+
84
+ def get_current_personal_org(self, explicit_org_id=None):
85
+ page = self.list_joined_orgs()
86
+ org_list = page.get("list") or []
87
+ if explicit_org_id is not None:
88
+ for org in org_list:
89
+ if str(org.get("id")) == str(explicit_org_id):
90
+ if org.get("isSpecificOrg") is True:
91
+ return org
92
+ raise InvoiceClientError(
93
+ "PERSONAL_ORG_REQUIRED",
94
+ "The specified org is not a personal org. This skill only supports personal invoice flows.",
95
+ )
96
+ raise InvoiceClientError("ORG_NOT_FOUND", "The specified personal org was not found.")
97
+
98
+ for org in org_list:
99
+ if org.get("isSpecificOrg") is True:
100
+ return org
101
+
102
+ raise InvoiceClientError("PERSONAL_ORG_NOT_FOUND", "No available personal org was found for the current account.")
103
+
104
+ def list_invoice_templates(self, org_id):
105
+ return self._request("GET", "/orgs/{0}/invoices/templates".format(org_id))
106
+
107
+ def save_invoice_template(self, org_id, payload):
108
+ return self._request(
109
+ "POST",
110
+ "/orgs/{0}/invoices/templates".format(org_id),
111
+ json_body=payload,
112
+ )
113
+
114
+ def delete_invoice_template(self, org_id, template_id):
115
+ return self._request(
116
+ "DELETE",
117
+ "/orgs/{0}/invoices/templates/{1}".format(org_id, template_id),
118
+ )
119
+
120
+ def list_invoiceable_recharge_records(self, org_id, params=None):
121
+ return self._request(
122
+ "GET",
123
+ "/orgs/{0}/invoices/recharge-records".format(org_id),
124
+ params=params or {"pageNum": 1, "pageSize": 20},
125
+ )
126
+
127
+ def create_invoice_application(self, org_id, payload):
128
+ return self._request(
129
+ "POST",
130
+ "/orgs/{0}/invoices/applications".format(org_id),
131
+ json_body=payload,
132
+ )
133
+
134
+ def update_invoice_application(self, org_id, application_no, payload):
135
+ return self._request(
136
+ "PUT",
137
+ "/orgs/{0}/invoices/applications/{1}".format(org_id, application_no),
138
+ json_body=payload,
139
+ )
140
+
141
+ def list_invoice_applications(self, org_id, params=None):
142
+ return self._request(
143
+ "GET",
144
+ "/orgs/{0}/invoices/applications".format(org_id),
145
+ params=params or {},
146
+ )
147
+
148
+ def get_invoice_application(self, org_id, application_no):
149
+ page = self.list_invoice_applications(
150
+ org_id,
151
+ params={"pageNum": 1, "pageSize": 1, "applicationNo": application_no},
152
+ )
153
+ if not isinstance(page, dict):
154
+ raise InvoiceClientError("UNEXPECTED_RESPONSE", "Invoice list API returned an unexpected response shape.")
155
+ items = page.get("list") or []
156
+ if not items:
157
+ raise InvoiceClientError("APPLICATION_NOT_FOUND", "The target invoice application was not found.")
158
+ return items[0]
159
+
160
+ def download_invoice_file(self, org_id, application_no):
161
+ return self._request(
162
+ "GET",
163
+ "/orgs/{0}/invoices/applications/{1}/download".format(org_id, application_no),
164
+ )
@@ -0,0 +1,63 @@
1
+ """Structured output helpers for the invoice assistant skeleton."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from dto import ActionResult
8
+
9
+
10
+ def build_result(status, action, message, extra=None):
11
+ return ActionResult(status=status, action=action, message=message, extra=extra or {})
12
+
13
+
14
+ def emit_result(result):
15
+ payload = {
16
+ "STATUS": result.status,
17
+ "ACTION": result.action,
18
+ "MESSAGE": result.message,
19
+ }
20
+ payload.update(result.extra or {})
21
+
22
+ for key, value in payload.items():
23
+ if value is None:
24
+ continue
25
+ if isinstance(value, (dict, list, tuple)):
26
+ value = json.dumps(value, ensure_ascii=False)
27
+ print("{0}={1}".format(key, value))
28
+
29
+
30
+ def failure(action, error_code, error_message, extra=None):
31
+ data = {"ERROR_CODE": error_code, "ERROR_MESSAGE": error_message}
32
+ if extra:
33
+ data.update(extra)
34
+ return build_result(
35
+ status="failed",
36
+ action=action,
37
+ message=error_message,
38
+ extra=data,
39
+ )
40
+
41
+
42
+ def not_implemented(action, message):
43
+ return build_result(
44
+ status="not_implemented",
45
+ action=action,
46
+ message=message,
47
+ extra={"ERROR_CODE": "API_NOT_IMPLEMENTED"},
48
+ )
49
+
50
+
51
+ def need_confirmation(action, summary, message, extra=None):
52
+ data = {
53
+ "CONFIRM_REQUIRED": "true",
54
+ "CONFIRM_SUMMARY": summary,
55
+ }
56
+ if extra:
57
+ data.update(extra)
58
+ return build_result(
59
+ status="need_confirmation",
60
+ action=action,
61
+ message=message,
62
+ extra=data,
63
+ )
@@ -0,0 +1,58 @@
1
+ """Prepare context for invoice actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from invoice_client import InvoiceClientError
6
+ from output import build_result, failure
7
+ from submit_flow import INVOICE_ELIGIBLE_START_DATE, _filter_eligible_records
8
+
9
+
10
+ def run_prepare(request, auth_context, client):
11
+ if not auth_context.is_authenticated:
12
+ return failure(
13
+ action=request.action,
14
+ error_code="AUTH_CONTEXT_MISSING",
15
+ error_message="Current Sophnet auth context is missing. Please log in again.",
16
+ )
17
+
18
+ try:
19
+ org = client.get_current_personal_org(request.org_id)
20
+ templates = client.list_invoice_templates(org.get("id")) or []
21
+ records_page = client.list_invoiceable_recharge_records(
22
+ org.get("id"),
23
+ params={"pageNum": 1, "pageSize": 20, "invoiceableOnly": "true"},
24
+ )
25
+ applications_page = client.list_invoice_applications(
26
+ org.get("id"),
27
+ params={"pageNum": 1, "pageSize": 10},
28
+ )
29
+ except InvoiceClientError as exc:
30
+ return failure(
31
+ action=request.action,
32
+ error_code=exc.code,
33
+ error_message=exc.message,
34
+ )
35
+
36
+ records = records_page.get("list") or [] if isinstance(records_page, dict) else []
37
+ eligible_records, ineligible_records = _filter_eligible_records(records)
38
+ applications = applications_page.get("list") or [] if isinstance(applications_page, dict) else []
39
+
40
+ return build_result(
41
+ status="succeeded",
42
+ action=request.action,
43
+ message="Resolved the current personal org and loaded invoice context.",
44
+ extra={
45
+ "ORG_ID": str(org.get("id")),
46
+ "ORG_NAME": org.get("name") or "",
47
+ "IS_SPECIFIC_ORG": str(bool(org.get("isSpecificOrg"))).lower(),
48
+ "TEMPLATE_COUNT": str(len(templates)),
49
+ "AVAILABLE_RECHARGE_RECORD_COUNT": str(len(eligible_records)),
50
+ "INELIGIBLE_RECHARGE_RECORD_COUNT": str(len(ineligible_records)),
51
+ "INVOICE_ELIGIBLE_START_DATE": INVOICE_ELIGIBLE_START_DATE,
52
+ "APPLICATION_COUNT": str(len(applications)),
53
+ "TEMPLATES": templates[:10],
54
+ "AVAILABLE_RECHARGE_RECORDS": eligible_records[:10],
55
+ "INELIGIBLE_RECHARGE_RECORDS": ineligible_records[:10],
56
+ "RECENT_APPLICATIONS": applications[:10],
57
+ },
58
+ )
@@ -0,0 +1,95 @@
1
+ """Risk classification rules for the invoice assistant skeleton."""
2
+
3
+ from __future__ import annotations
4
+
5
+ KEYWORD_LAST = "\u4e0a\u6b21"
6
+ KEYWORD_RECENT = "\u6700\u8fd1"
7
+ KEYWORD_LATEST = "\u6700\u65b0"
8
+
9
+ TEMPLATE_FIELD_NAMES = (
10
+ "buyerType",
11
+ "title",
12
+ "taxNo",
13
+ "addressPhone",
14
+ "bankAccount",
15
+ "invoiceType",
16
+ )
17
+
18
+
19
+ def _normalize_text(value):
20
+ if value is None:
21
+ return ""
22
+ return str(value).strip()
23
+
24
+
25
+ def _selected_trans_nos(request):
26
+ return [item.strip() for item in str(request.recharge_trans_nos or "").split(",") if item.strip()]
27
+
28
+
29
+ def _requests_latest_template(request):
30
+ text = request.user_request or ""
31
+ return bool(
32
+ request.use_latest_template
33
+ or KEYWORD_LAST in text
34
+ or KEYWORD_RECENT in text
35
+ or KEYWORD_LATEST in text
36
+ )
37
+
38
+
39
+ def _same_template(left, right):
40
+ if not left or not right:
41
+ return False
42
+ left_id = left.get("id")
43
+ right_id = right.get("id")
44
+ if left_id is not None and right_id is not None:
45
+ return str(left_id) == str(right_id)
46
+ return _normalize_text(left.get("templateName")) == _normalize_text(right.get("templateName"))
47
+
48
+
49
+ def _payload_matches_template(payload, template):
50
+ if not payload or not template:
51
+ return False
52
+ for field_name in TEMPLATE_FIELD_NAMES:
53
+ if _normalize_text(payload.get(field_name)) != _normalize_text(template.get(field_name)):
54
+ return False
55
+ return True
56
+
57
+
58
+ def classify_submit_risk(request, payload=None, template=None, latest_template=None, selected_records=None):
59
+ selected_records = selected_records or []
60
+ values = _selected_trans_nos(request)
61
+
62
+ if request.save_as_template:
63
+ return {
64
+ "level": "high",
65
+ "reason": "save_as_template_requires_confirmation",
66
+ }
67
+ if len(values) != 1 or len(selected_records) != 1:
68
+ return {
69
+ "level": "high",
70
+ "reason": "single_recharge_record_required",
71
+ }
72
+ if not _requests_latest_template(request):
73
+ return {
74
+ "level": "high",
75
+ "reason": "latest_template_reuse_not_requested",
76
+ }
77
+ if not template or not latest_template:
78
+ return {
79
+ "level": "high",
80
+ "reason": "latest_template_not_available",
81
+ }
82
+ if not _same_template(template, latest_template):
83
+ return {
84
+ "level": "high",
85
+ "reason": "selected_template_is_not_latest",
86
+ }
87
+ if not _payload_matches_template(payload, template):
88
+ return {
89
+ "level": "high",
90
+ "reason": "invoice_fields_changed_from_template",
91
+ }
92
+ return {
93
+ "level": "low",
94
+ "reason": "latest_template_with_single_record_and_matching_fields",
95
+ }
@@ -0,0 +1,69 @@
1
+ """Invoice application status query skeleton."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from invoice_client import InvoiceClientError
6
+ from output import build_result, failure
7
+
8
+
9
+ def _build_status_message(application):
10
+ status = application.get("status")
11
+ application_no = application.get("applicationNo")
12
+ if status == "ISSUED":
13
+ return "Invoice application {0} has been issued and is ready for download.".format(application_no)
14
+ if status == "REJECTED":
15
+ return "Invoice application {0} was rejected. Check the reject reason before retrying.".format(application_no)
16
+ return "Invoice application {0} is still being processed.".format(application_no)
17
+
18
+
19
+ def run_status(request, auth_context, client):
20
+ if not auth_context.is_authenticated:
21
+ return failure(
22
+ action=request.action,
23
+ error_code="AUTH_CONTEXT_MISSING",
24
+ error_message="Current Sophnet auth context is missing. Please log in again.",
25
+ )
26
+
27
+ try:
28
+ org = client.get_current_personal_org(request.org_id)
29
+ params = {"pageNum": 1, "pageSize": 10}
30
+ if request.application_no:
31
+ params["applicationNo"] = request.application_no
32
+ page = client.list_invoice_applications(org.get("id"), params=params)
33
+ except InvoiceClientError as exc:
34
+ return failure(
35
+ action=request.action,
36
+ error_code=exc.code,
37
+ error_message=exc.message,
38
+ )
39
+
40
+ items = []
41
+ if isinstance(page, dict):
42
+ items = page.get("list") or []
43
+ if not items:
44
+ return failure(
45
+ action=request.action,
46
+ error_code="NO_APPLICATION_FOUND",
47
+ error_message="No invoice application was found under the current personal org.",
48
+ extra={"ORG_ID": str(org.get("id"))},
49
+ )
50
+
51
+ application = items[0]
52
+ return build_result(
53
+ status="succeeded",
54
+ action=request.action,
55
+ message=_build_status_message(application),
56
+ extra={
57
+ "ORG_ID": str(org.get("id")),
58
+ "ORG_NAME": org.get("name") or "",
59
+ "APPLICATION_NO": application.get("applicationNo") or "",
60
+ "APPLICATION_STATUS": application.get("status") or "",
61
+ "TITLE": application.get("title") or "",
62
+ "AMOUNT": str(application.get("amount") or ""),
63
+ "HAS_INVOICE_FILE": str(bool(application.get("hasInvoiceFile"))).lower(),
64
+ "APPLIED_AT": application.get("appliedAt") or "",
65
+ "ISSUED_AT": application.get("issuedAt") or "",
66
+ "REJECT_REASON": application.get("rejectReason") or "",
67
+ "RESULT_COUNT": str(len(items)),
68
+ },
69
+ )